Source code for pref_voting.pref_grade_profile

"""
File: pref_grade_profile.py  (refactored)
Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)

A class that represents profiles in which each voter submits both a (truncated)
strict weak order and an assignment of grades.

Design
------
The ranking side of a ``PrefGradeProfile`` is exactly a :class:`ProfileWithTies`,
so this class **inherits** ``ProfileWithTies`` and gets the entire ranking-query API
for free (``support``, ``margin``, ``condorcet_winner``, ``copeland_scores``,
``plurality_scores``, ``borda_scores``, ``cycles``, the ``num_*`` family, the margin/
support/majority graphs, pickling, ...).

The grade side is exactly a :class:`GradeProfile`, so this class **composes** one
(``self._grade_profile``) and delegates the grade-query API to it (renaming ``margin``
to ``grade_margin`` to avoid colliding with the ranking margin).

Only the genuinely combined behavior is bespoke: the constructor, the conversions, the
lockstep mutations (``remove_candidates``, ``remove_empty_rankings``, ``anonymize``),
``display``, ``__eq__``, ``__add__``, and ``description``.
"""

import copy

import numpy as np
from tabulate import tabulate

from pref_voting.grade_profiles import GradeProfile
from pref_voting.profiles_with_ties import ProfileWithTies


[docs] class PrefGradeProfile(ProfileWithTies): """An anonymous profile in which each voter submits both a (truncated) strict weak order and an assignment of grades. See the module docstring for the design. :param rankings: List of rankings (each a :class:`Ranking` or a dict). :param grade_maps: List of grade maps (each a :class:`Grade` or a dict). :param grades: List of grades. :param rcounts: Number of voters for each ranking/grade pair. :param candidates: List of candidates (defaults to the union appearing in the data). :param cmap: candidate-name map. :param gmap: grade-name map. :param grade_order: grades from largest to smallest (numeric ``>`` if None). """ def __init__(self, rankings, grade_maps, grades, rcounts=None, candidates=None, cmap=None, gmap=None, grade_order=None): assert len(rankings) == len(grade_maps), ( "The number of rankings must be the same as the number of grade maps" ) # Determine the candidate set from BOTH the rankings and the grade maps, # then let the ranking side (ProfileWithTies) and grade side (GradeProfile) # share that exact candidate set. if candidates is None: get_r = lambda r: list(r.keys()) if isinstance(r, dict) else r.cands get_g = lambda g: list(g.keys()) if isinstance(g, dict) else g.graded_candidates candidates = sorted( set(c for r in rankings for c in get_r(r)) | set(c for g in grade_maps for c in get_g(g)) ) # --- ranking side: initialise the ProfileWithTies base --- super().__init__(rankings, rcounts=rcounts, candidates=candidates, cmap=cmap) # --- grade side: compose a GradeProfile over the same candidates/counts --- self._grade_profile = GradeProfile( grade_maps, grades, gcounts=self.rcounts, candidates=self.candidates, cmap=self.cmap, gmap=gmap, grade_order=grade_order, ) # ------------------------------------------------------------------ # Grade-side state exposed as read-only properties (delegated) # ------------------------------------------------------------------ @property def grades(self): return self._grade_profile.grades @property def grade_order(self): return self._grade_profile.grade_order @property def use_grade_order(self): return self._grade_profile.use_grade_order @property def gmap(self): return self._grade_profile.gmap @property def can_sum_grades(self): return self._grade_profile.can_sum_grades @property def compare_function(self): return self._grade_profile.compare_function @property def _grades(self): return self._grade_profile._grades @property def grades_counts(self): return self._grade_profile.grades_counts @property def grade_functions(self): return self._grade_profile.grade_functions # ------------------------------------------------------------------ # Grade-side query API (delegated to the composed GradeProfile) # ------------------------------------------------------------------ def has_grade(self, c): return self._grade_profile.has_grade(c) def grade_margin(self, c1, c2, use_extended=False): return self._grade_profile.margin(c1, c2, use_extended=use_extended) def proportion(self, cand, grade): return self._grade_profile.proportion(cand, grade) def proportion_with_grade(self, cand, grade): return self._grade_profile.proportion_with_grade(cand, grade) def proportion_with_higher_grade(self, cand, grade): return self._grade_profile.proportion_with_higher_grade(cand, grade) def proportion_with_lower_grade(self, cand, grade): return self._grade_profile.proportion_with_lower_grade(cand, grade) def sum(self, c): return self._grade_profile.sum(c) def avg(self, c): return self._grade_profile.avg(c) def max(self, c): return self._grade_profile.max(c) def min(self, c): return self._grade_profile.min(c) def median(self, c, use_lower=True, use_average=False): return self._grade_profile.median(c, use_lower=use_lower, use_average=use_average) def sum_grade_function(self): return self._grade_profile.sum_grade_function() def avg_grade_function(self): return self._grade_profile.avg_grade_function() def approval_scores(self): return self._grade_profile.approval_scores() # ------------------------------------------------------------------ # Conversions # ------------------------------------------------------------------
[docs] def to_ranking_profile(self): """Return a :class:`ProfileWithTies` of just the ranking side.""" return ProfileWithTies( self._rankings, rcounts=self.rcounts, candidates=self.candidates, cmap=self.cmap, )
[docs] def to_grade_profile(self): """Return a :class:`GradeProfile` of just the grade side.""" return GradeProfile( [g.as_dict() for g in self._grades], self.grades, gcounts=self.rcounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
# ------------------------------------------------------------------ # Lockstep mutations (must keep rankings and grades aligned) # ------------------------------------------------------------------
[docs] def remove_candidates(self, cands_to_ignore): """Remove ``cands_to_ignore`` from both the rankings and the grades.""" updated_rankings = [ {c: r for c, r in rank.rmap.items() if c not in cands_to_ignore} for rank in self._rankings ] updated_grade_maps = [ {c: g.val(c) for c in g.graded_candidates if c not in cands_to_ignore} for g in self._grades ] new_candidates = [c for c in self.candidates if c not in cands_to_ignore] restricted = PrefGradeProfile( updated_rankings, updated_grade_maps, self.grades, rcounts=self.rcounts, candidates=new_candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, ) if self.using_extended_strict_preference: restricted.use_extended_strict_preference() return restricted
[docs] def remove_empty_rankings(self): """Remove ballots whose ranking is empty, keeping grades in lockstep.""" new_rankings, new_grade_maps, new_rcounts = [], [], [] for r, g, c in zip(self._rankings, self._grades, self.rcounts): if len(r.cands) != 0: new_rankings.append(r.rmap) new_grade_maps.append(g.as_dict()) new_rcounts.append(c) # rebuild both sides in place ProfileWithTies.__init__( self, new_rankings, rcounts=new_rcounts, candidates=self.candidates, cmap=self.cmap, ) self._grade_profile = GradeProfile( new_grade_maps, self.grades, gcounts=self.rcounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
[docs] def anonymize(self): """Group identical (ranking, grade) ballots together.""" anon_rankings, anon_grades, rcounts = [], [], [] for r, g in zip(self.rankings, self.grade_functions): for i, (_r, _g) in enumerate(zip(anon_rankings, anon_grades)): if r == _r and g.as_dict() == _g.as_dict(): rcounts[i] += 1 break else: anon_rankings.append(r) anon_grades.append(g) rcounts.append(1) prof = PrefGradeProfile( anon_rankings, [g.as_dict() for g in anon_grades], self.grades, rcounts=rcounts, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, ) if self.using_extended_strict_preference: prof.use_extended_strict_preference() return prof
# ------------------------------------------------------------------ # Display / description # ------------------------------------------------------------------
[docs] def display(self, cmap=None, style="pretty", curr_cands=None, show_grades=True, show_totals=False): """Display the ranking table and (optionally) the grade table.""" _rankings = [r.normalize_ranks() or r for r in copy.deepcopy(self._rankings)] curr_cands = curr_cands if curr_cands is not None else self.candidates cmap = cmap if cmap is not None else self.cmap _ranked = [r for r in _rankings if len(r.ranks) > 0] existing_ranks = list(range( min(min(r.ranks) for r in _ranked), max(max(r.ranks) for r in _ranked) + 1, )) if len(_ranked) > 0 else [] print("Rankings:") print(tabulate( [[" ".join(str(cmap[c]) for c in r.cands_at_rank(rank) if c in curr_cands) for r in _rankings] for rank in existing_ranks], self.rcounts, tablefmt=style, )) if show_grades: print("\nGrades:") if show_totals: sum_fn = self.sum_grade_function() headers = [""] + self.rcounts + ["Sum", "Median"] tbl = [[cmap[c]] + [self.gmap[g(c)] if g.has_grade(c) else "" for g in self._grades] + [sum_fn(c), self.median(c)] for c in curr_cands] else: headers = [""] + self.rcounts tbl = [[cmap[c]] + [self.gmap[g(c)] if g.has_grade(c) else "" for g in self._grades] for c in curr_cands] print(tabulate(tbl, headers=headers))
def visualize_grades(self): self.to_grade_profile().visualize()
[docs] def description(self): return ( f"PrefGradeProfile(" f"{[r.rmap for r in self._rankings]}, " f"{[g.as_dict() for g in self._grades]}, " f"{self.grades}, " f"rcounts={[int(c) for c in self.rcounts]}, " f"cmap={self.cmap})" )
# ------------------------------------------------------------------ # Equality / addition (consider BOTH rankings and grades) # ------------------------------------------------------------------ def __eq__(self, other): rankings, grades = self.rankings, self.grade_functions other_rankings = other.rankings[:] other_grades = other.grade_functions[:] for r1, g1 in zip(rankings, grades): for i, (r2, g2) in enumerate(zip(other_rankings, other_grades)): if r1 == r2 and g1.as_dict() == g2.as_dict(): del other_rankings[i] del other_grades[i] break else: return False return not other_rankings __hash__ = None def __add__(self, other): return PrefGradeProfile( self._rankings + other._rankings, [g.as_dict() for g in self._grades] + [g.as_dict() for g in other._grades], self.grades, rcounts=self.rcounts + other.rcounts, candidates=sorted(set(self.candidates + other.candidates)), )