From 253b829d4a91a90e9322f3caf2b6ad6758b2a0d7 Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 17:26:01 -0700 Subject: [PATCH 1/6] Added generation of preference-approval profiles --- docs/source/generate_profiles.md | 7 ++ pref_voting/generate_profiles.py | 125 ++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/docs/source/generate_profiles.md b/docs/source/generate_profiles.md index 78509a69..814199dc 100644 --- a/docs/source/generate_profiles.md +++ b/docs/source/generate_profiles.md @@ -350,6 +350,13 @@ We use the [prefsampling](https://comsoc-community.github.io/prefsampling/index. ``` +### Generate a Preference-Approval Profile + +```{eval-rst} +.. autofunction:: pref_voting.generate_profiles.generate_pref_approval_profile + +``` + ## Enumerating profiles ### Enumerate anonymous profiles diff --git a/pref_voting/generate_profiles.py b/pref_voting/generate_profiles.py index 984fc338..0c6ad617 100644 --- a/pref_voting/generate_profiles.py +++ b/pref_voting/generate_profiles.py @@ -2,7 +2,7 @@ File: gen_profiles.py Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu) Date: December 7, 2020 - Updated: May 25, 2025 + Updated: April 11, 2026 Functions to generate profiles @@ -18,8 +18,8 @@ from scipy.stats import gamma from itertools import permutations from pref_voting.helper import weak_compositions, weak_orders - from pref_voting.profiles_with_ties import ProfileWithTies +from pref_voting.pref_grade_profile import PrefGradeProfile from ortools.linear_solver import pywraplp from prefsampling.ordinal import impartial, impartial_anonymous, urn, plackett_luce, didi, stratification, single_peaked_conitzer, single_peaked_walsh, single_peaked_circle, single_crossing, euclidean, mallows @@ -507,6 +507,127 @@ def generate_profile_with_groups( return profs[0] if num_profiles == 1 else profs +def generate_pref_approval_profile( + num_candidates, + num_voters, + num_profiles=1, + **kwargs, +): + """Generate PrefGradeProfile(s) with approval grades using a given probability model. + + Each voter's ranking is generated using a probability model for + preferences, and each voter's approval set is generated using a + probability model for approvals. The two models are specified jointly + via the ``probmodel`` keyword argument, which has the form + ``"_"``. + + Currently supported approval models: + + * ``"uniform"`` -- each voter independently selects a uniformly random + cutoff position *k* in {0, 1, ..., num_candidates}, and approves the + candidates ranked in the top *k* positions. The approval set is + therefore upward closed in the voter's preference relation. + + The preference model (the part before the first ``"_"``) can be any + model accepted by :func:`get_rankings` (e.g., ``"IC"``, ``"MALLOWS"``, + ``"URN"``, ``"MALLOWS-RELPHI"``, etc.). In the future, joint models + that do not decompose into separate preference and approval components + can be added as single-token ``probmodel`` values (without an + underscore separator). + + Args: + num_candidates (int): The number of candidates. + num_voters (int): The number of voters. + num_profiles (int): The number of profiles to generate (default 1). + kwargs: Parameters for the probability model. The ``probmodel`` + keyword (default ``"IC_uniform"``) selects the joint model. + The ``seed`` keyword controls reproducibility. Any remaining + keywords are forwarded to the preference model (e.g., ``phi`` + for Mallows). + + Returns: + PrefGradeProfile or list[PrefGradeProfile]: A single profile if + ``num_profiles`` is 1, otherwise a list of profiles. + + Examples: + + .. code-block:: python + + # IC preferences with uniform approval cutoff (default) + pgp = generate_pref_approval_profile(4, 10) + + # Mallows preferences with uniform approval cutoff + pgp = generate_pref_approval_profile(4, 10, probmodel="MALLOWS_uniform", phi=0.5) + + # URN preferences with uniform approval cutoff + pgp = generate_pref_approval_profile(4, 10, probmodel="URN_uniform", alpha=10) + """ + # --- parse probmodel --- + if 'probmodel' in kwargs: + probmodel = kwargs.pop('probmodel') + elif 'probmod' in kwargs: + probmodel = kwargs.pop('probmod') + else: + probmodel = "IC_uniform" + + # Split into preference model and approval model at the first underscore + if "_" in probmodel: + pref_model, approval_model = probmodel.split("_", 1) + else: + # Future: joint models that don't decompose + raise ValueError( + f"Unrecognized joint probability model: '{probmodel}'. " + f"Expected a compound model of the form '_' " + f"(e.g., 'IC_uniform', 'MALLOWS_uniform')." + ) + + seed = kwargs.get('seed', None) + rng = np.random.default_rng(seed) + + grades = [0, 1] + gmap = {0: "Not Approved", 1: "Approved"} + + # Build kwargs for get_rankings (put pref_model back as probmodel) + ranking_kwargs = dict(kwargs) + ranking_kwargs['probmodel'] = pref_model + + profiles = [] + for _ in range(num_profiles): + rankings = get_rankings(num_candidates, num_voters, **ranking_kwargs) + + ranking_dicts = [] + grade_maps = [] + + for voter_ranking in rankings: + # voter_ranking: list of candidates from most to least preferred + rdict = {int(c): pos + 1 for pos, c in enumerate(voter_ranking)} + ranking_dicts.append(rdict) + + if approval_model == "uniform": + # Approve top k candidates, k uniform in {0, ..., num_candidates} + cutoff = int(rng.integers(0, num_candidates + 1)) + else: + raise ValueError( + f"Unrecognized approval model: '{approval_model}'. " + f"Currently supported: 'uniform'." + ) + + gdict = {} + for pos, c in enumerate(voter_ranking): + gdict[int(c)] = 1 if pos < cutoff else 0 + grade_maps.append(gdict) + + profiles.append( + PrefGradeProfile( + ranking_dicts, + grade_maps, + grades, + gmap=gmap, + ) + ) + + return profiles[0] if num_profiles == 1 else profiles + #### # Enumerating profiles #### From 173367c0210a38fb10b72b7323f1e31bb69e85e9 Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 20:08:50 -0700 Subject: [PATCH 2/6] Added functions to check for single-peakedness --- docs/source/analysis_of_procedures.md | 66 ++++ docs/source/analysis_of_profiles.md | 10 + docs/source/analysis_overview.md | 66 +--- docs/source/index.rst | 4 +- docs/source/single_peakedness.md | 36 ++ pref_voting/generate_profiles.py | 8 +- pref_voting/single_peakedness.py | 528 ++++++++++++++++++++++++++ tests/test_pref_grade_profile.py | 91 +++++ tests/test_single_peakedness.py | 263 +++++++++++++ 9 files changed, 1004 insertions(+), 68 deletions(-) create mode 100644 docs/source/analysis_of_procedures.md create mode 100644 docs/source/analysis_of_profiles.md create mode 100644 docs/source/single_peakedness.md create mode 100644 pref_voting/single_peakedness.py create mode 100644 tests/test_pref_grade_profile.py create mode 100644 tests/test_single_peakedness.py diff --git a/docs/source/analysis_of_procedures.md b/docs/source/analysis_of_procedures.md new file mode 100644 index 00000000..230b0be8 --- /dev/null +++ b/docs/source/analysis_of_procedures.md @@ -0,0 +1,66 @@ +Analysis of Procedures +========== + +Functions that can be used to compare and contrast voting methods. + + +## Profiles with Different Winners + + +```{eval-rst} + + +.. autofunction:: pref_voting.analysis.find_profiles_with_different_winners + + +``` + +## Condorcet Efficiency + + +```{eval-rst} + + +.. autofunction:: pref_voting.analysis.condorcet_efficiency_data + + +``` + +## Resoluteness + + +```{eval-rst} + + +.. autofunction:: pref_voting.analysis.resoluteness_data + + +``` + +## Axiom Violations + +```{eval-rst} + +.. autofunction:: pref_voting.analysis.axiom_violations_data + + +``` + +## Binomial Confidence Interval + +```{eval-rst} + +.. autofunction:: pref_voting.analysis.binomial_confidence_interval + +``` + +## Means with Estimated Standard Error + + +```{eval-rst} + + +.. autofunction:: pref_voting.analysis.means_with_estimated_standard_error + + +``` diff --git a/docs/source/analysis_of_profiles.md b/docs/source/analysis_of_profiles.md new file mode 100644 index 00000000..d5fc0b58 --- /dev/null +++ b/docs/source/analysis_of_profiles.md @@ -0,0 +1,10 @@ +Analysis of Profiles +========== + +Functions that analyze a preference profile independently of any particular voting procedure, measuring structural properties of the electorate. + +:::{toctree} +:maxdepth: 2 + +single_peakedness +::: diff --git a/docs/source/analysis_overview.md b/docs/source/analysis_overview.md index 6450675d..f3db64fa 100644 --- a/docs/source/analysis_overview.md +++ b/docs/source/analysis_overview.md @@ -1,68 +1,8 @@ Overview ========== -Functions that can be used to compare and contrast voting methods. - - -## Profiles with Different Winners - - -```{eval-rst} - - -.. autofunction:: pref_voting.analysis.find_profiles_with_different_winners - - -``` - -## Condorcet Efficiency - - -```{eval-rst} - - -.. autofunction:: pref_voting.analysis.condorcet_efficiency_data - - -``` - -## Resoluteness - - -```{eval-rst} - - -.. autofunction:: pref_voting.analysis.resoluteness_data - - -``` - -## Axiom Violations - -```{eval-rst} - -.. autofunction:: pref_voting.analysis.axiom_violations_data - - -``` - -## Binomial Confidence Interval - -```{eval-rst} - -.. autofunction:: pref_voting.analysis.binomial_confidence_interval - -``` - -## Means with Estimated Standard Error - - -```{eval-rst} - - -.. autofunction:: pref_voting.analysis.means_with_estimated_standard_error - - -``` +``pref_voting`` provides two complementary kinds of analysis tools: +- **Analysis of procedures** — functions that compare and contrast collective decision procedures (voting methods), for example by finding profiles on which different methods disagree, estimating Condorcet efficiency, measuring resoluteness, or counting axiom violations. +- **Analysis of profiles** — functions that analyze a preference profile independently of any particular procedure, measuring structural properties of the electorate such as how close the profile is to satisfying a domain restriction. diff --git a/docs/source/index.rst b/docs/source/index.rst index 56cdfc5b..9bf2579c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -162,7 +162,9 @@ Contents :caption: Analysis analysis_overview - + analysis_of_procedures + analysis_of_profiles + Index ---------------------- diff --git a/docs/source/single_peakedness.md b/docs/source/single_peakedness.md new file mode 100644 index 00000000..6e116a87 --- /dev/null +++ b/docs/source/single_peakedness.md @@ -0,0 +1,36 @@ +Single-Peakedness +========== + +Functions to analyze single-peakedness of preference profiles. + +A preference profile is *single-peaked* with respect to an axis (linear order over the candidates) if every voter has a unique most-preferred candidate (peak) and the voter's preferences decrease monotonically in both directions from the peak along the axis. + +A profile is *k-maverick single-peaked* with respect to an axis if all but *k* voters are single-peaked with respect to that axis. The minimum *k* over all axes measures how far the profile is from being single-peaked. + +**Reference:** Faliszewski, Hemaspaandra & Hemaspaandra (2014), "The complexity of manipulative attacks in nearly single-peaked electorates", *Artificial Intelligence* 207, 69-99. [DOI](https://doi.org/10.1016/j.artint.2013.11.004) + +## Checking Single-Peakedness of Individual Rankings + +```{eval-rst} + +.. autofunction:: pref_voting.single_peakedness.is_single_peaked + +``` + +## Profile-Level Analysis + +### Counting Mavericks + +```{eval-rst} + +.. autofunction:: pref_voting.single_peakedness.num_mavericks + +``` + +### Finding the Minimum k + +```{eval-rst} + +.. autofunction:: pref_voting.single_peakedness.min_k_maverick_single_peaked + +``` diff --git a/pref_voting/generate_profiles.py b/pref_voting/generate_profiles.py index 0c6ad617..cf222b1f 100644 --- a/pref_voting/generate_profiles.py +++ b/pref_voting/generate_profiles.py @@ -528,9 +528,9 @@ def generate_pref_approval_profile( candidates ranked in the top *k* positions. The approval set is therefore upward closed in the voter's preference relation. - The preference model (the part before the first ``"_"``) can be any + The preference model (the part before the last ``"_"``) can be any model accepted by :func:`get_rankings` (e.g., ``"IC"``, ``"MALLOWS"``, - ``"URN"``, ``"MALLOWS-RELPHI"``, etc.). In the future, joint models + ``"URN"``, ``"MALLOWS-RELPHI"``, ``"single_peaked_conitzer"``, etc.). In the future, joint models that do not decompose into separate preference and approval components can be added as single-token ``probmodel`` values (without an underscore separator). @@ -570,9 +570,9 @@ def generate_pref_approval_profile( else: probmodel = "IC_uniform" - # Split into preference model and approval model at the first underscore + # Split into preference model and approval model at the last underscore if "_" in probmodel: - pref_model, approval_model = probmodel.split("_", 1) + pref_model, approval_model = probmodel.rsplit("_", 1) else: # Future: joint models that don't decompose raise ValueError( diff --git a/pref_voting/single_peakedness.py b/pref_voting/single_peakedness.py new file mode 100644 index 00000000..7b734856 --- /dev/null +++ b/pref_voting/single_peakedness.py @@ -0,0 +1,528 @@ +""" + File: single_peakedness.py + Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu) + Date: April 11, 2026 + + Functions to analyze single-peakedness of preference profiles. + + A preference profile is *single-peaked* with respect to an axis (linear order + over the candidates) if every voter has a unique most-preferred candidate (peak) + and the voter's preferences decrease monotonically in both directions from the + peak along the axis. + + A profile is *k-maverick single-peaked* with respect to an axis if all but *k* + voters are single-peaked with respect to that axis. The minimum *k* over all + axes measures how far the profile is from being single-peaked. + + Reference for k-maverick single-peakedness: + Faliszewski, Hemaspaandra & Hemaspaandra (2014), "The complexity of + manipulative attacks in nearly single-peaked electorates", *Artificial + Intelligence* 207, 69-99. + https://doi.org/10.1016/j.artint.2013.11.004 + + Handling of ties in rankings + ---------------------------- + Rankings with ties (weak orders) can be handled in four ways, controlled by + the ``tied_ranking_handling`` parameter: + + - ``'maverick'`` (default): Rankings with ties are always counted as mavericks. + - ``'possibly_sp'``: A weak order is possibly single-peaked with respect to the axis if there exists some way to + break all ties that yields a linear order that is single-peaked with respect to the axis. This is the most + permissive notion. + Reference: Lackner (AAAI 2014); Fitzsimmons & Lackner (JAIR 2020), + "Incomplete Preferences in Single-Peaked Electorates", *Journal of + Artificial Intelligence Research* 67, 797-833. https://doi.org/10.1613/jair.1.11577 + - ``'single_plateaued'``: A weak order is single-plateaued with respect to + the axis if the top indifference class forms a contiguous interval on the + axis (the "plateau") and preferences strictly worsen moving away from the + plateau on each side. Ties across opposite sides of the plateau are + allowed (two candidates on opposite slopes may have the same rank), but + same-side ties below the plateau are not. + Reference: Berga & Moreno (2009), "Strategic requirements with indifference: + single-peaked versus single-plateaued preferences", *Social Choice and + Welfare* 32(2), 275-298. + - ``'black_sp'``: A weak order is Black-single-peaked with respect to the + axis if it has a unique peak and preferences strictly worsen moving away + from the peak on each side. Ties across opposite sides of the peak are + allowed, but same-side ties below the peak are not. Equivalently, this + is single-plateauedness with a plateau of size 1. + Reference: Black (1948), "On the Rationale of Group Decision-making", + *Journal of Political Economy* 56(1), 23-34. For the weak-order + formulation used here, see also Fitzsimmons & Lackner (JAIR 2020, + Section 6). + + The hierarchy among these notions (for individual voters) is: + Black SP ⊊ Single-plateaued ⊊ Possibly SP. + See Fitzsimmons & Lackner (JAIR 2020, Section 6) for a detailed comparison. + + Handling of truncated rankings + ------------------------------ + Truncated linear orders (where some candidates are unranked) are handled by + requiring that the ranked candidates form a contiguous segment of the axis and + that the ranking is single-peaked on that sub-axis. This corresponds to the + treatment of "top orders" (weak orders where all unranked candidates are tied + at the bottom) in Lackner (AAAI 2014) and Fitzsimmons & Lackner (JAIR 2020, + Sections 2 and 5). + + For truncated weak orders (both truncated and with ties among ranked candidates), + the contiguity check is applied first, then the appropriate SP notion is checked + on the sub-axis of ranked candidates. +""" + +from itertools import permutations +from pref_voting.profiles import Profile +from pref_voting.profiles_with_ties import ProfileWithTies +from pref_voting.rankings import Ranking + + +def is_single_peaked(ranking, axis, num_cands=None, + treat_truncated_as_maverick=False, + tied_ranking_handling='maverick'): + """ + Check if a ranking is single-peaked with respect to the given axis. + + This function accepts either a list (a linear ranking, as used with + :class:`Profile`) or a :class:`Ranking` object (as used with + :class:`ProfileWithTies`), and automatically handles complete, truncated, + and tied rankings. + + Args: + ranking (list or Ranking): The voter's ranking. If a list, it should + contain candidates from most preferred to least preferred (a linear + order, possibly truncated). If a :class:`Ranking` object, ties and + truncation are detected automatically. + axis (list or tuple): Candidates in the left-to-right axis order. + num_cands (int or None): Total number of candidates in the election. + Required when ``ranking`` is a :class:`Ranking` object, to detect + whether the ranking is truncated. Ignored when ``ranking`` is a list + (in which case ``len(axis)`` is used). + treat_truncated_as_maverick (bool): If True, voters who don't rank all + candidates are counted as mavericks. If False (default), truncated + rankings are checked for compatibility with single-peakedness + (ranked candidates must form a contiguous segment of the axis and + be single-peaked on that segment). + tied_ranking_handling (str): How to handle rankings with ties (only + relevant when ``ranking`` is a :class:`Ranking` object with ties). + One of ``'maverick'`` (default), ``'possibly_sp'``, + ``'single_plateaued'``, ``'black_sp'``. + See module docstring for details. + + Returns: + bool: True if the ranking is single-peaked-compatible with respect to + the axis. + + Examples: + + With a list (linear ranking from a :class:`Profile`): + + .. code-block:: python + + >>> from pref_voting.single_peakedness import is_single_peaked + + >>> is_single_peaked([1, 0, 2], [0, 1, 2]) + True + >>> is_single_peaked([0, 2, 1], [0, 1, 2]) + False + + With a :class:`Ranking` object (from a :class:`ProfileWithTies`): + + .. code-block:: python + + >>> from pref_voting.rankings import Ranking + >>> from pref_voting.single_peakedness import is_single_peaked + + >>> # Linear ranking: 0 > 1 > 2 + >>> is_single_peaked(Ranking({0: 1, 1: 2, 2: 3}), [0, 1, 2], num_cands=3) + True + >>> # Weak order with tie: {0, 1} > 2 + >>> is_single_peaked(Ranking({0: 1, 1: 1, 2: 2}), [0, 1, 2], num_cands=3, + ... tied_ranking_handling='possibly_sp') + True + """ + axis = _validate_axis(axis) + axis_set = set(axis) + + if isinstance(ranking, Ranking): + ranked_set = set(ranking.rmap.keys()) + if not ranked_set.issubset(axis_set): + raise ValueError("ranking contains candidates not on the axis") + if num_cands is None: + num_cands = len(axis) + elif num_cands != len(axis): + raise ValueError("num_cands must equal len(axis)") + return _is_ranking_sp(ranking, num_cands, axis, + treat_truncated_as_maverick, tied_ranking_handling) + + # List input: linear order (possibly truncated) + ranking = list(ranking) + if len(ranking) != len(set(ranking)): + raise ValueError("ranking must not contain duplicates") + if not set(ranking).issubset(axis_set): + raise ValueError("ranking contains candidates not on the axis") + if len(ranking) == len(axis): + return _is_linear_sp(ranking, axis) + if treat_truncated_as_maverick: + return False + return _is_truncated_linear_sp(ranking, axis) + +def num_mavericks(profile, axis, treat_truncated_as_maverick=False, + tied_ranking_handling='maverick'): + """ + Count the number of voters whose rankings are NOT single-peaked with + respect to the given axis. + + Args: + profile (Profile or ProfileWithTies): The preference profile. + axis (list or tuple): Candidates in the left-to-right axis order. + treat_truncated_as_maverick (bool): If True, voters who don't rank all + candidates are counted as mavericks. If False (default), truncated + rankings are checked for compatibility with single-peakedness + (ranked candidates must form a contiguous segment of the axis and + be single-peaked on that segment). + tied_ranking_handling (str): How to handle rankings with ties. + One of ``'maverick'`` (default), ``'possibly_sp'``, + ``'single_plateaued'``, ``'black_sp'``. + See module docstring for details. + + Returns: + int: The number of maverick voters. + + Example: + + .. code-block:: python + + from pref_voting.profiles import Profile + from pref_voting.single_peakedness import num_mavericks + + prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) + # 0 > 2 > 1 is not single-peaked on axis [0, 1, 2] + num_mavericks(prof, [0, 1, 2]) # returns 1 + """ + axis = _validate_axis(axis) + num_cands = len(profile.candidates) + if set(axis) != set(profile.candidates): + raise ValueError("axis must contain exactly the profile candidates") + + # Use anonymize() to deduplicate rankings + if isinstance(profile, Profile): + anon = profile.to_profile_with_ties().anonymize() + else: + anon = profile.anonymize() + + maverick_count = 0 + for ranking, count in zip(anon._rankings, anon.rcounts): + if not _is_ranking_sp(ranking, num_cands, axis, + treat_truncated_as_maverick, tied_ranking_handling): + maverick_count += int(count) + return maverick_count + +def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, + tied_ranking_handling='maverick'): + """ + Find the minimum *k* such that the profile is *k*-maverick single-peaked + with respect to some ordering of the candidates. + + This function iterates over all possible axes (permutations of candidates) + and returns the axis that minimizes the number of maverick voters. An axis + and its reverse are equivalent for single-peakedness, so only half of the + permutations are checked. + + Suitable for small numbers of candidates (up to about 8). + + Args: + profile (Profile or ProfileWithTies): The preference profile. + treat_truncated_as_maverick (bool): If True, voters who don't rank all + candidates are counted as mavericks. If False (default), truncated + rankings are checked for compatibility. + tied_ranking_handling (str): How to handle rankings with ties. + One of ``'maverick'`` (default), ``'possibly_sp'``, + ``'single_plateaued'``, ``'black_sp'``. + See module docstring for details. + + Returns: + tuple: ``(min_k, best_axis)`` where ``min_k`` (int) is the minimum + number of mavericks and ``best_axis`` (list) is an axis achieving it. + + Example: + + .. code-block:: python + + from pref_voting.profiles import Profile + from pref_voting.single_peakedness import min_k_maverick_single_peaked + + prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) + min_k, best_axis = min_k_maverick_single_peaked(prof) + # min_k = 1, best_axis = [0, 1, 2] + + References: + Faliszewski, Hemaspaandra & Hemaspaandra (2014), "The complexity of + manipulative attacks in nearly single-peaked electorates", *Artificial + Intelligence* 207, 69-99. + https://doi.org/10.1016/j.artint.2013.11.004 + """ + candidates = profile.candidates + m = len(candidates) + if m <= 1: + return 0, list(candidates) + + num_cands = m + + # Use anonymize() to deduplicate rankings + if isinstance(profile, Profile): + anon = profile.to_profile_with_ties().anonymize() + else: + anon = profile.anonymize() + + rankings_and_counts = list(zip(anon._rankings, anon.rcounts)) + + best_k = profile.num_voters + best_axis = list(candidates) + + # Symmetry: axis and its reverse are equivalent for single-peakedness. + # We only consider permutations where candidates[0] is in the first half; + # when m is odd and candidates[0] is at the center, break the tie by + # requiring axis[0] < axis[-1]. + first = candidates[0] + half = m // 2 + + for perm in permutations(candidates): + axis = list(perm) + pos = axis.index(first) + if pos > half or (pos == half and m % 2 == 0): + continue + if pos == half and axis[0] > axis[-1]: + continue + + k = sum(int(c) for r, c in rankings_and_counts + if not _is_ranking_sp(r, num_cands, axis, + treat_truncated_as_maverick, + tied_ranking_handling)) + + if k < best_k: + best_k = k + best_axis = list(axis) + if best_k == 0: + break + + return best_k, best_axis + + +# ============================================================================= +# Internal helpers +# ============================================================================= + +def _validate_axis(axis): + """Validate that the axis is a duplicate-free list of candidates.""" + axis = list(axis) + if len(axis) != len(set(axis)): + raise ValueError("axis must not contain duplicates") + return axis + +def _is_linear_sp(ranking, axis): + """Check if a complete linear ranking is single-peaked w.r.t. the axis. + + Uses the recursive characterization: the bottom-ranked candidate must be at + one of the two extremes of the axis, and recursively for the rest. + """ + if len(ranking) <= 2: + return True + bottom = ranking[-1] + if bottom == axis[0]: + return _is_linear_sp(ranking[:-1], axis[1:]) + elif bottom == axis[-1]: + return _is_linear_sp(ranking[:-1], axis[:-1]) + else: + return False + + +def _is_truncated_linear_sp(ranked_cands, axis): + """Check if a truncated linear ranking is SP-compatible w.r.t. the axis. + + Requires that the ranked candidates form a contiguous segment of the axis + and that the ranking is single-peaked on that sub-axis. + """ + if len(ranked_cands) <= 1: + return True + + ranked_set = set(ranked_cands) + positions = [i for i, c in enumerate(axis) if c in ranked_set] + + if len(positions) != positions[-1] - positions[0] + 1: + return False + + sub_axis = axis[positions[0]:positions[-1] + 1] + return _is_linear_sp(ranked_cands, sub_axis) + + +def _is_ranking_sp(ranking, num_cands, axis, treat_truncated_as_maverick, + tied_ranking_handling): + """Check if a pref_voting Ranking object is SP-compatible w.r.t. an axis.""" + is_complete = ranking.num_ranked_candidates() == num_cands + + if ranking.has_tie(): + if tied_ranking_handling == 'maverick': + return False + indiff_classes = [sorted(ranking.cands_at_rank(r)) + for r in ranking.ranks] + if is_complete: + return _is_weak_order_sp(indiff_classes, axis, + tied_ranking_handling) + if treat_truncated_as_maverick: + return False + return _is_truncated_weak_order_sp(indiff_classes, axis, + tied_ranking_handling) + + sorted_cands = sorted(ranking.rmap.keys(), key=lambda c: ranking.rmap[c]) + if is_complete: + return _is_linear_sp(sorted_cands, axis) + if treat_truncated_as_maverick: + return False + return _is_truncated_linear_sp(sorted_cands, axis) + + +def _is_weak_order_sp(indiff_classes, axis, method): + """Dispatch to the appropriate weak-order SP check.""" + if method == 'possibly_sp': + return _is_possibly_sp_weak_order(indiff_classes, axis) + elif method == 'single_plateaued': + return _is_single_plateaued_weak_order(indiff_classes, axis) + elif method == 'black_sp': + return _is_black_sp_weak_order(indiff_classes, axis) + else: + raise ValueError( + f"Unknown tied_ranking_handling: {method!r}. " + f"Expected 'possibly_sp', 'single_plateaued', or 'black_sp'." + ) + + +def _is_truncated_weak_order_sp(indiff_classes, axis, method): + """Check if a truncated weak order is SP-compatible w.r.t. the axis. + + First checks contiguity, then applies the appropriate SP check on the + sub-axis. + """ + all_ranked = set() + for ic in indiff_classes: + all_ranked.update(ic) + + if len(all_ranked) <= 1: + return True + + positions = [i for i, c in enumerate(axis) if c in all_ranked] + + if len(positions) != positions[-1] - positions[0] + 1: + return False + + sub_axis = axis[positions[0]:positions[-1] + 1] + return _is_weak_order_sp(indiff_classes, sub_axis, method) + + +def _is_possibly_sp_weak_order(indiff_classes, axis): + """ + Check if a weak order is possibly single-peaked w.r.t. the given axis. + + A weak order (given as indifference classes from most to least preferred) + is possibly single-peaked if there exists a linear extension (tie-breaking) + that is single-peaked. This is checked by a bottom-up peeling algorithm: + starting from the least preferred class, each class's candidates must be + removable from the left and/or right extremes of the remaining axis. + + References: + Lackner (AAAI 2014), "Incomplete Preferences in Single-Peaked + Electorates". + + Fitzsimmons & Lackner (JAIR 2020), "Incomplete Preferences in + Single-Peaked Electorates", *Journal of Artificial Intelligence + Research* 67, 797-833. + """ + current_axis = list(axis) + + # Process classes from bottom (least preferred) to top (most preferred) + for ic in reversed(indiff_classes): + ic_set = set(ic) + # Peel from left + while current_axis and current_axis[0] in ic_set: + ic_set.discard(current_axis[0]) + current_axis.pop(0) + # Peel from right + while current_axis and current_axis[-1] in ic_set: + ic_set.discard(current_axis[-1]) + current_axis.pop() + if ic_set: + # Some candidates in this class are stuck in the interior + return False + return True + + +def _is_single_plateaued_weak_order(indiff_classes, axis): + """ + Check if a weak order is single-plateaued w.r.t. the given axis. + + The top indifference class must form a contiguous interval on the axis + (the "plateau"), and preferences must strictly worsen moving away from + the plateau on each side. Ties across opposite sides of the plateau are + permitted; same-side ties below the plateau are not. + + References: + Berga & Moreno (2009), "Strategic requirements with indifference: + single-peaked versus single-plateaued preferences", *Social Choice and + Welfare* 32(2), 275-298. + + Fitzsimmons & Lackner (JAIR 2020), "Incomplete Preferences in + Single-Peaked Electorates", Section 6. + """ + if not indiff_classes: + return True + + top_set = set(indiff_classes[0]) + positions = [i for i, c in enumerate(axis) if c in top_set] + + if len(positions) != len(top_set): + return False # not all top candidates found on axis + if positions[-1] - positions[0] + 1 != len(positions): + return False # not contiguous + + rank_of = {} + for rank_idx, ic in enumerate(indiff_classes): + for c in ic: + rank_of[c] = rank_idx + + # Left of plateau: moving away from plateau, ranks must strictly worsen. + prev_rank = 0 + for i in range(positions[0] - 1, -1, -1): + r = rank_of[axis[i]] + if r <= prev_rank: + return False + prev_rank = r + + # Right of plateau: moving away from plateau, ranks must strictly worsen. + prev_rank = 0 + for i in range(positions[-1] + 1, len(axis)): + r = rank_of[axis[i]] + if r <= prev_rank: + return False + prev_rank = r + + return True + + +def _is_black_sp_weak_order(indiff_classes, axis): + """ + Check if a weak order is Black single-peaked w.r.t. the given axis. + + Equivalent to single-plateauedness with a plateau of size 1: there must + be a unique peak, preferences strictly worsen moving away from the peak + on each side, and cross-side ties are permitted. + + References: + Black (1948), "On the Rationale of Group Decision-making", *Journal of + Political Economy* 56(1), 23-34. + + Fitzsimmons & Lackner (JAIR 2020), "Incomplete Preferences in + Single-Peaked Electorates", Section 6. + """ + if not indiff_classes: + return True + if len(indiff_classes[0]) != 1: + return False + return _is_single_plateaued_weak_order(indiff_classes, axis) diff --git a/tests/test_pref_grade_profile.py b/tests/test_pref_grade_profile.py new file mode 100644 index 00000000..38475feb --- /dev/null +++ b/tests/test_pref_grade_profile.py @@ -0,0 +1,91 @@ +import pytest + +from pref_voting.pref_grade_profile import PrefGradeProfile +from pref_voting.rankings import Ranking +from pref_voting.weighted_majority_graphs import MarginGraph, MajorityGraph, SupportGraph + + +@pytest.fixture +def paired_pref_grade_profile(): + return PrefGradeProfile( + [ + {0: 1, 1: 2, 2: 3}, + {1: 1, 2: 1, 0: 2}, + ], + [ + {0: 5, 1: 3, 2: 1}, + {0: 2, 1: 4, 2: 4}, + ], + [1, 2, 3, 4, 5], + rcounts=[2, 3], + ) + + +def test_pref_grade_profile_expands_rankings_and_grades_in_lockstep( + paired_pref_grade_profile, +): + paired_ballots = [ + (ranking.rmap, grade.as_dict()) + for ranking, grade in zip( + paired_pref_grade_profile.rankings, + paired_pref_grade_profile.grade_functions, + ) + ] + + assert len(paired_pref_grade_profile.rankings) == 5 + assert len(paired_pref_grade_profile.grade_functions) == 5 + assert paired_ballots == [ + ({0: 1, 1: 2, 2: 3}, {0: 5, 1: 3, 2: 1}), + ({0: 1, 1: 2, 2: 3}, {0: 5, 1: 3, 2: 1}), + ({1: 1, 2: 1, 0: 2}, {0: 2, 1: 4, 2: 4}), + ({1: 1, 2: 1, 0: 2}, {0: 2, 1: 4, 2: 4}), + ({1: 1, 2: 1, 0: 2}, {0: 2, 1: 4, 2: 4}), + ] + + +def test_pref_grade_profile_pairwise_margins_use_rankings_not_grades(): + pgprof = PrefGradeProfile( + [{0: 1, 1: 2}, {1: 1, 0: 2}], + [{0: 0, 1: 1}, {0: 0, 1: 1}], + [0, 1], + rcounts=[3, 1], + ) + + assert pgprof.support(0, 1) == 3 + assert pgprof.support(1, 0) == 1 + assert pgprof.margin(0, 1) == 2 + assert pgprof.margin(1, 0) == -2 + assert pgprof.majority_prefers(0, 1) + assert not pgprof.majority_prefers(1, 0) + + # The grade ballots disagree with the ranking ballots here, so this stays separate. + assert pgprof.grade_margin(0, 1) == -4 + + +def test_pref_grade_profile_graphs_and_conversions_preserve_ranking_side(): + pgprof = PrefGradeProfile( + [Ranking({0: 1, 1: 2}), {1: 1, 0: 2}], + [{0: 2, 1: 0}, {0: 0, 1: 2}], + [0, 1, 2], + rcounts=[2, 1], + ) + + margin_graph = pgprof.margin_graph() + support_graph = pgprof.support_graph() + majority_graph = pgprof.majority_graph() + ranking_profile = pgprof.to_ranking_profile() + grade_profile = pgprof.to_grade_profile() + + assert isinstance(margin_graph, MarginGraph) + assert isinstance(support_graph, SupportGraph) + assert isinstance(majority_graph, MajorityGraph) + + assert margin_graph.margin(0, 1) == 1 + assert support_graph.support(0, 1) == 2 + assert majority_graph.majority_prefers(0, 1) + + assert ranking_profile.rcounts == [2, 1] + assert ranking_profile.margin(0, 1) == pgprof.margin(0, 1) + + assert grade_profile.gcounts == [2, 1] + assert grade_profile.margin(0, 1) == pgprof.grade_margin(0, 1) diff --git a/tests/test_single_peakedness.py b/tests/test_single_peakedness.py new file mode 100644 index 00000000..67042154 --- /dev/null +++ b/tests/test_single_peakedness.py @@ -0,0 +1,263 @@ +import pytest + +from pref_voting.profiles import Profile +from pref_voting.profiles_with_ties import ProfileWithTies +from pref_voting.rankings import Ranking +from pref_voting.single_peakedness import ( + is_single_peaked, + num_mavericks, + min_k_maverick_single_peaked, +) + + +# --------------------------------------------------------------------------- +# Linear rankings (lists) +# --------------------------------------------------------------------------- + +def test_linear_sp_basic(): + assert is_single_peaked([1, 0, 2], [0, 1, 2]) is True + assert is_single_peaked([0, 1, 2], [0, 1, 2]) is True + assert is_single_peaked([2, 1, 0], [0, 1, 2]) is True + + +def test_linear_not_sp(): + # 0 > 2 > 1: bottom 1 is in the interior of the axis + assert is_single_peaked([0, 2, 1], [0, 1, 2]) is False + + +def test_linear_two_candidate_always_sp(): + assert is_single_peaked([0, 1], [0, 1]) is True + assert is_single_peaked([1, 0], [0, 1]) is True + + +def test_truncated_list_contiguous_is_sp(): + # Ranked candidates form a contiguous segment of the axis + assert is_single_peaked([1, 0], [0, 1, 2]) is True + assert is_single_peaked([1, 2], [0, 1, 2]) is True + + +def test_truncated_list_noncontiguous_not_sp(): + assert is_single_peaked([0, 2], [0, 1, 2]) is False + + +def test_truncated_list_treated_as_maverick(): + assert is_single_peaked([1, 0], [0, 1, 2], treat_truncated_as_maverick=True) is False + + +# --------------------------------------------------------------------------- +# Ranking objects: default 'maverick' handling +# --------------------------------------------------------------------------- + +def test_ranking_linear_sp(): + r = Ranking({0: 1, 1: 2, 2: 3}) + assert is_single_peaked(r, [0, 1, 2], num_cands=3) is True + + +def test_ranking_with_tie_default_is_maverick(): + r = Ranking({0: 1, 1: 1, 2: 2}) + assert is_single_peaked(r, [0, 1, 2], num_cands=3) is False + + +# --------------------------------------------------------------------------- +# possibly_sp +# --------------------------------------------------------------------------- + +def test_possibly_sp_tie_at_top(): + r = Ranking({0: 1, 1: 1, 2: 2}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='possibly_sp' + ) is True + + +def test_possibly_sp_interior_class_not_extreme(): + # axis=[a,b,c,d,e], {c} > {a,e} > {b,d}: no SP linear extension exists + # because the bottom class {b,d} sits in the interior of the axis. + r = Ranking({'c': 1, 'a': 2, 'e': 2, 'b': 3, 'd': 3}) + assert is_single_peaked( + r, ['a', 'b', 'c', 'd', 'e'], num_cands=5, + tied_ranking_handling='possibly_sp', + ) is False + + +def test_possibly_sp_truncated_contiguous(): + # Ranked candidates {1,2,3} form a contiguous segment on axis [0..4] + r = Ranking({2: 1, 1: 1, 3: 2}) + assert is_single_peaked( + r, [0, 1, 2, 3, 4], num_cands=5, tied_ranking_handling='possibly_sp' + ) is True + + +# --------------------------------------------------------------------------- +# single_plateaued +# --------------------------------------------------------------------------- + +def test_plateau_contiguous_strict_below(): + # {1,2} > 0 > 3 on axis [0,1,2,3]: plateau contiguous, strict on each side. + r = Ranking({1: 1, 2: 1, 0: 2, 3: 3}) + assert is_single_peaked( + r, [0, 1, 2, 3], num_cands=4, tied_ranking_handling='single_plateaued' + ) is True + + +def test_plateau_noncontiguous_not_sp(): + # {0,2} are the top class but not contiguous on axis [0,1,2,3] + r = Ranking({0: 1, 2: 1, 1: 2, 3: 3}) + assert is_single_peaked( + r, [0, 1, 2, 3], num_cands=4, tied_ranking_handling='single_plateaued' + ) is False + + +def test_plateau_cross_side_tie_allowed(): + # 1 > 0 ~ 2 on axis [0,1,2]: 0 and 2 are on opposite sides of the peak, + # so the cross-side tie is fine. This was incorrectly rejected before. + r = Ranking({1: 1, 0: 2, 2: 2}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='single_plateaued' + ) is True + + +def test_plateau_cross_side_tie_with_plateau(): + # {1,2} > {0,3} on axis [0,1,2,3]: plateau contiguous; the lower tie is + # across opposite sides of the plateau. + r = Ranking({1: 1, 2: 1, 0: 2, 3: 2}) + assert is_single_peaked( + r, [0, 1, 2, 3], num_cands=4, tied_ranking_handling='single_plateaued' + ) is True + + +def test_plateau_same_side_tie_below_not_allowed(): + # 2 > 1 ~ 0 on axis [0,1,2]: 0 and 1 are on the same side of peak 2. + r = Ranking({2: 1, 1: 2, 0: 2}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='single_plateaued' + ) is False + + +def test_plateau_monotonicity_must_strictly_worsen_away_from_plateau(): + # {2,3} > 0 > 1 on axis [0,1,2,3]: moving LEFT from the plateau we hit 1 + # (rank 2) then 0 (rank 1) — that's an improvement, not a worsening. + # This was incorrectly accepted before. + r = Ranking({2: 1, 3: 1, 0: 2, 1: 3}) + assert is_single_peaked( + r, [0, 1, 2, 3], num_cands=4, tied_ranking_handling='single_plateaued' + ) is False + + +# --------------------------------------------------------------------------- +# black_sp +# --------------------------------------------------------------------------- + +def test_black_sp_unique_peak_strict(): + r = Ranking({1: 1, 0: 2, 2: 3}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='black_sp' + ) is True + + +def test_black_sp_cross_side_tie_allowed(): + # 1 > 0 ~ 2: unique peak, 0 and 2 on opposite sides. + r = Ranking({1: 1, 0: 2, 2: 2}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='black_sp' + ) is True + + +def test_black_sp_same_side_tie_forbidden(): + # 2 > 1 ~ 0 > 3 on axis [0,1,2,3]: 0 and 1 are on the same side of peak 2, + # so the tie is not permitted under Black SP. Was incorrectly accepted. + r = Ranking({2: 1, 1: 2, 0: 2, 3: 3}) + assert is_single_peaked( + r, [0, 1, 2, 3], num_cands=4, tied_ranking_handling='black_sp' + ) is False + + +def test_black_sp_no_unique_peak(): + r = Ranking({0: 1, 2: 1, 1: 2}) + assert is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='black_sp' + ) is False + + +# --------------------------------------------------------------------------- +# Profile-level: num_mavericks and min_k_maverick_single_peaked +# --------------------------------------------------------------------------- + +def test_num_mavericks_linear_profile(): + prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) + assert num_mavericks(prof, [0, 1, 2]) == 1 + + +def test_min_k_linear_profile(): + prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) + min_k, best_axis = min_k_maverick_single_peaked(prof) + assert min_k == 1 + assert best_axis in ([0, 1, 2], [2, 1, 0]) + + +def test_min_k_sp_profile_is_zero(): + prof = Profile([[0, 1, 2], [1, 0, 2], [2, 1, 0]]) + min_k, best_axis = min_k_maverick_single_peaked(prof) + assert min_k == 0 + assert best_axis is not None + + +def test_min_k_single_candidate(): + prof = Profile([[0], [0]]) + assert min_k_maverick_single_peaked(prof) == (0, [0]) + + +def test_min_k_returns_valid_axis_when_all_voters_maverick(): + # Every ballot is fully tied, default handling counts every voter as a + # maverick for every axis. best_axis must still be a valid axis, not None. + prof = ProfileWithTies([{0: 1, 1: 1, 2: 1}] * 3) + min_k, best_axis = min_k_maverick_single_peaked(prof) + assert min_k == 3 + assert best_axis is not None + assert sorted(best_axis) == [0, 1, 2] + + +def test_num_mavericks_with_possibly_sp(): + # 1 > 0 ~ 2 is possibly SP on [0,1,2] (break the tie either way). + prof = ProfileWithTies([{1: 1, 0: 2, 2: 2}, {0: 1, 1: 2, 2: 3}]) + assert num_mavericks( + prof, [0, 1, 2], tied_ranking_handling='possibly_sp' + ) == 0 + + +def test_invalid_tied_ranking_handling_raises(): + r = Ranking({0: 1, 1: 1, 2: 2}) + with pytest.raises(ValueError): + is_single_peaked( + r, [0, 1, 2], num_cands=3, tied_ranking_handling='bogus' + ) + + +def test_is_single_peaked_list_duplicate_candidate_raises(): + with pytest.raises(ValueError): + is_single_peaked([0, 0, 1], [0, 1, 2]) + + +def test_is_single_peaked_ranking_candidate_not_on_axis_raises(): + r = Ranking({0: 1, 3: 2}) + with pytest.raises(ValueError): + is_single_peaked(r, [0, 1, 2], num_cands=3) + + +def test_is_single_peaked_num_cands_mismatch_raises(): + r = Ranking({0: 1, 1: 2, 2: 3}) + with pytest.raises(ValueError): + is_single_peaked(r, [0, 1, 2], num_cands=4) + + +def test_num_mavericks_axis_must_match_profile_candidates(): + prof = Profile([[0, 1, 2]]) + with pytest.raises( + ValueError, match="axis must contain exactly the profile candidates" + ): + num_mavericks(prof, [0, 1]) + + +def test_num_mavericks_axis_duplicate_candidate_raises(): + prof = Profile([[0, 1, 2]]) + with pytest.raises(ValueError): + num_mavericks(prof, [0, 1, 1]) From 07c4068a77fc2e2c295e379074768193bb5d7b7a Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 20:15:18 -0700 Subject: [PATCH 3/6] Fixed use of random seed in generation of preference-approval profiles --- pref_voting/generate_profiles.py | 15 +++++++++------ tests/test_generate_profiles.py | 27 ++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/pref_voting/generate_profiles.py b/pref_voting/generate_profiles.py index cf222b1f..f6a88e4c 100644 --- a/pref_voting/generate_profiles.py +++ b/pref_voting/generate_profiles.py @@ -581,18 +581,21 @@ def generate_pref_approval_profile( f"(e.g., 'IC_uniform', 'MALLOWS_uniform')." ) - seed = kwargs.get('seed', None) + seed = kwargs.pop('seed', None) rng = np.random.default_rng(seed) grades = [0, 1] gmap = {0: "Not Approved", 1: "Approved"} - # Build kwargs for get_rankings (put pref_model back as probmodel) - ranking_kwargs = dict(kwargs) - ranking_kwargs['probmodel'] = pref_model - profiles = [] for _ in range(num_profiles): + ranking_kwargs = dict(kwargs) + ranking_kwargs['probmodel'] = pref_model + if seed is not None: + ranking_kwargs['seed'] = seed if num_profiles == 1 else int( + rng.integers(0, np.iinfo(np.int64).max) + ) + rankings = get_rankings(num_candidates, num_voters, **ranking_kwargs) ranking_dicts = [] @@ -919,4 +922,4 @@ def minimal_profile_from_edge_order(cands, edge_order): print("ERROR: Found non integer, ", v.solution_value()) return None - return Profile(_ranks, rcounts = _rcounts) \ No newline at end of file + return Profile(_ranks, rcounts = _rcounts) diff --git a/tests/test_generate_profiles.py b/tests/test_generate_profiles.py index 0f6d369f..0873a721 100644 --- a/tests/test_generate_profiles.py +++ b/tests/test_generate_profiles.py @@ -1,5 +1,6 @@ import pytest -from pref_voting.generate_profiles import generate_profile, generate_profile_with_groups, generate_truncated_profile, minimal_profile_from_edge_order +from pref_voting.generate_profiles import generate_profile, generate_profile_with_groups, generate_truncated_profile, minimal_profile_from_edge_order, generate_pref_approval_profile +from pref_voting.pref_grade_profile import PrefGradeProfile from pref_voting.profiles import Profile from pref_voting.profiles_with_ties import ProfileWithTies @@ -322,6 +323,30 @@ def test_generate_multiple_profiles(): assert all([len(prof.candidates) == 4 for prof in profs]) assert all([len(prof.rankings) == 3 for prof in profs]) +def test_generate_pref_approval_profile(): + + prof = generate_pref_approval_profile(4, 5, seed=1) + assert type(prof) == PrefGradeProfile + assert len(prof.candidates) == 4 + assert prof.num_voters == 5 + assert prof.grades == [0, 1] + + for ranking, grade_function in zip(prof._rankings, prof._grades): + ordered_cands = [c for c, _ in sorted(ranking.rmap.items(), key=lambda item: item[1])] + grades = [grade_function.as_dict()[c] for c in ordered_cands] + assert grades == sorted(grades, reverse=True) + +def test_generate_pref_approval_profile_multiple_profiles_have_distinct_rankings(): + + profs = generate_pref_approval_profile(4, 5, num_profiles=2, seed=7) + assert type(profs) == list + assert len(profs) == 2 + assert all(type(prof) == PrefGradeProfile for prof in profs) + + rankings0 = [r.rmap for r in profs[0]._rankings] + rankings1 = [r.rmap for r in profs[1]._rankings] + assert rankings0 != rankings1 + def test_generate_profile_with_groups(): prof = generate_profile_with_groups(4, 3, From f0650e186b6c787f0078638a368913e77698d776 Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 20:18:40 -0700 Subject: [PATCH 4/6] Update single_peakedness.py --- pref_voting/single_peakedness.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pref_voting/single_peakedness.py b/pref_voting/single_peakedness.py index 7b734856..3839349a 100644 --- a/pref_voting/single_peakedness.py +++ b/pref_voting/single_peakedness.py @@ -307,9 +307,7 @@ def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, return best_k, best_axis -# ============================================================================= # Internal helpers -# ============================================================================= def _validate_axis(axis): """Validate that the axis is a duplicate-free list of candidates.""" From 69fa3c2ab49921952839832c19d011ab54639ef5 Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 20:20:43 -0700 Subject: [PATCH 5/6] Update analysis_overview.md --- docs/source/analysis_overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/analysis_overview.md b/docs/source/analysis_overview.md index f3db64fa..bf8f5b5c 100644 --- a/docs/source/analysis_overview.md +++ b/docs/source/analysis_overview.md @@ -3,6 +3,6 @@ Overview ``pref_voting`` provides two complementary kinds of analysis tools: -- **Analysis of procedures** — functions that compare and contrast collective decision procedures (voting methods), for example by finding profiles on which different methods disagree, estimating Condorcet efficiency, measuring resoluteness, or counting axiom violations. +- **Analysis of procedures** — functions that analyze collective decision procedures, for example by finding profiles on which different methods disagree, estimating Condorcet efficiency, measuring resoluteness, or counting axiom violations. - **Analysis of profiles** — functions that analyze a preference profile independently of any particular procedure, measuring structural properties of the electorate such as how close the profile is to satisfying a domain restriction. From 170e0cfde7477a3d8d2f8e061656631fa965b77c Mon Sep 17 00:00:00 2001 From: "Wesley H. Holliday" Date: Sat, 11 Apr 2026 23:09:06 -0700 Subject: [PATCH 6/6] Added third output of k_maverick function --- pref_voting/single_peakedness.py | 47 +++++++++++++++++++++++++++----- tests/test_single_peakedness.py | 33 +++++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/pref_voting/single_peakedness.py b/pref_voting/single_peakedness.py index 3839349a..b5c5eb93 100644 --- a/pref_voting/single_peakedness.py +++ b/pref_voting/single_peakedness.py @@ -240,8 +240,12 @@ def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, See module docstring for details. Returns: - tuple: ``(min_k, best_axis)`` where ``min_k`` (int) is the minimum - number of mavericks and ``best_axis`` (list) is an axis achieving it. + tuple: ``(min_k, best_axis, sp_profile)`` where ``min_k`` (int) is + the minimum number of mavericks, ``best_axis`` (list) is an axis + achieving it, and ``sp_profile`` is the anonymized sub-profile of + non-maverick voters (a :class:`Profile` if the input is a + :class:`Profile`, a :class:`ProfileWithTies` if the input is a + :class:`ProfileWithTies`, or ``None`` if all voters are mavericks). Example: @@ -251,8 +255,8 @@ def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, from pref_voting.single_peakedness import min_k_maverick_single_peaked prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) - min_k, best_axis = min_k_maverick_single_peaked(prof) - # min_k = 1, best_axis = [0, 1, 2] + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) + # min_k = 1, best_axis = [0, 1, 2], sp_prof has 4 voters References: Faliszewski, Hemaspaandra & Hemaspaandra (2014), "The complexity of @@ -262,13 +266,15 @@ def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, """ candidates = profile.candidates m = len(candidates) + is_profile = isinstance(profile, Profile) + if m <= 1: - return 0, list(candidates) + return 0, list(candidates), profile.anonymize() num_cands = m # Use anonymize() to deduplicate rankings - if isinstance(profile, Profile): + if is_profile: anon = profile.to_profile_with_ties().anonymize() else: anon = profile.anonymize() @@ -304,7 +310,34 @@ def min_k_maverick_single_peaked(profile, treat_truncated_as_maverick=False, if best_k == 0: break - return best_k, best_axis + # Build the anonymized sub-profile of non-maverick voters on best_axis + sp_rankings = [] + sp_counts = [] + for r, c in rankings_and_counts: + if _is_ranking_sp(r, num_cands, best_axis, + treat_truncated_as_maverick, tied_ranking_handling): + sp_rankings.append(r) + sp_counts.append(int(c)) + + if not sp_rankings: + sp_profile = None + elif is_profile: + sp_profile = Profile( + [sorted(r.rmap.keys(), key=lambda c: r.rmap[c]) + for r in sp_rankings], + rcounts=sp_counts, + cmap=profile.cmap, + ) + else: + from pref_voting.profiles_with_ties import ProfileWithTies + sp_profile = ProfileWithTies( + [r.rmap for r in sp_rankings], + rcounts=sp_counts, + candidates=list(candidates), + cmap=profile.cmap, + ) + + return best_k, best_axis, sp_profile # Internal helpers diff --git a/tests/test_single_peakedness.py b/tests/test_single_peakedness.py index 67042154..cd72bec9 100644 --- a/tests/test_single_peakedness.py +++ b/tests/test_single_peakedness.py @@ -189,31 +189,56 @@ def test_num_mavericks_linear_profile(): def test_min_k_linear_profile(): prof = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0], [2, 1, 0], [0, 2, 1]]) - min_k, best_axis = min_k_maverick_single_peaked(prof) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) assert min_k == 1 assert best_axis in ([0, 1, 2], [2, 1, 0]) + assert isinstance(sp_prof, Profile) + assert sp_prof.num_voters == 4 def test_min_k_sp_profile_is_zero(): prof = Profile([[0, 1, 2], [1, 0, 2], [2, 1, 0]]) - min_k, best_axis = min_k_maverick_single_peaked(prof) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) assert min_k == 0 assert best_axis is not None + assert sp_prof.num_voters == prof.num_voters def test_min_k_single_candidate(): prof = Profile([[0], [0]]) - assert min_k_maverick_single_peaked(prof) == (0, [0]) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) + assert min_k == 0 + assert best_axis == [0] + assert sp_prof.num_voters == 2 def test_min_k_returns_valid_axis_when_all_voters_maverick(): # Every ballot is fully tied, default handling counts every voter as a # maverick for every axis. best_axis must still be a valid axis, not None. prof = ProfileWithTies([{0: 1, 1: 1, 2: 1}] * 3) - min_k, best_axis = min_k_maverick_single_peaked(prof) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) assert min_k == 3 assert best_axis is not None assert sorted(best_axis) == [0, 1, 2] + assert sp_prof is None + + +def test_min_k_sp_profile_preserves_type(): + # ProfileWithTies input → ProfileWithTies output + prof = ProfileWithTies([{0: 1, 1: 2, 2: 3}, {1: 1, 0: 2, 2: 3}]) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) + assert isinstance(sp_prof, ProfileWithTies) + assert sp_prof.num_voters == prof.num_voters - min_k + + +def test_min_k_sp_profile_is_anonymized(): + # Duplicate rankings should be collapsed with rcounts + prof = Profile([[0, 1, 2], [0, 1, 2], [1, 0, 2], [2, 1, 0]]) + min_k, best_axis, sp_prof = min_k_maverick_single_peaked(prof) + assert min_k == 0 + assert sp_prof.num_voters == 4 + # Anonymized: 3 distinct ranking types, not 4 individual ballots + assert len(sp_prof._rankings) == 3 def test_num_mavericks_with_possibly_sp():