Source code for pref_voting.util_grade_profile

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

A profile that combines utilities and grades for each voter.

Design
------
The utility side of a ``UtilGradeProfile`` is exactly a :class:`UtilityProfile`,
so this class **inherits** ``UtilityProfile`` and gets the entire utility-query API
for free (``util_sum``, ``util_avg``, ``util_max``/``util_min``,
``sum_utility_function``/``avg_utility_function``, ``utilities``/``utilities_counts``,
``has_utility``, ``num_cands``, the ``candidates`` property, ...).

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 any ranking/utility notion).

Only the genuinely combined behavior is bespoke: the constructor (which must derive
the candidate set from *both* sides), the range/standard-score normalizations (which
must carry the grades along), the conversions, ``remove_candidates`` (a lockstep
mutation), ``display``, ``visualize_grades``, ``__eq__``, ``__add__``,
``as_dict``/``description``, and the ``__getstate__``/``__setstate__`` pair that keeps
the object picklable (the composed ``GradeProfile`` carries an unpicklable
``compare_function`` closure, so we pickle the data and rebuild instead).
"""

from tabulate import SEPARATING_LINE, tabulate

from pref_voting.grade_profiles import GradeProfile
from pref_voting.mappings import Grade, Utility
from pref_voting.profiles_with_ties import ProfileWithTies
from pref_voting.utility_profiles import UtilityProfile


[docs] class UtilGradeProfile(UtilityProfile): """An anonymous profile of utilities and grades. Each voter submits both a utility function over candidates and a grade assignment (e.g., approval/disapproval). This class packages both into a single object by **inheriting** :class:`UtilityProfile` (the utility side) and **composing** a :class:`GradeProfile` (the grade side). See the module docstring for the design. :param utilities: List of utility dicts or :class:`Utility` objects. :type utilities: list[dict] or list[Utility] :param grade_maps: List of grade dicts or :class:`Grade` objects. :type grade_maps: list[dict] or list[Grade] :param grades: List of possible grades. :type grades: list :param ucounts: List of the number of voters associated with each utility/grade pair. If not provided, 1 per entry. :type ucounts: list[int], optional :param candidates: List of candidates. If not provided, discovered from the utilities and grade maps. :type candidates: list, optional :param cmap: Dictionary mapping candidates to display names. :type cmap: dict, optional :param gmap: Dictionary mapping grades to display names. :type gmap: dict, optional :param grade_order: Grades listed from largest to smallest. If not provided, grades are sorted in descending numeric order. :type grade_order: list, optional :Example: .. code-block:: python ugprof = UtilGradeProfile( [{"x": 5, "y": 3, "z": 1}, {"x": 2, "y": 4, "z": 4}], [{"x": 1, "y": 0, "z": 0}, {"x": 0, "y": 1, "z": 1}], [0, 1], candidates=["x", "y", "z"], gmap={0: "Not Approved", 1: "Approved"}, ) """ def __init__( self, utilities, grade_maps, grades, ucounts=None, candidates=None, cmap=None, gmap=None, grade_order=None, ): """Constructor method""" assert len(utilities) == len(grade_maps), ( "utilities and grade_maps must have the same length" ) assert ucounts is None or len(utilities) == len(ucounts), ( "ucounts must have the same length as utilities" ) # ---- candidate set: union over BOTH the utilities and the grade maps ---- _candidates = list(candidates) if candidates is not None else [] for u in utilities: new = ( u.keys() if isinstance(u, dict) else (u.domain if isinstance(u, Utility) else []) ) _candidates += [x for x in new if x not in _candidates] for g in grade_maps: new = ( g.keys() if isinstance(g, dict) else (g.graded_candidates if isinstance(g, Grade) else []) ) _candidates += [x for x in new if x not in _candidates] all_candidates = sorted(set(_candidates)) # ---- utility side: initialise the UtilityProfile base ---- # (gives util_sum/avg/max/min, sum/avg_utility_function, utilities[_counts], # has_utility, num_cands, the candidates property, ...) super().__init__( [u if isinstance(u, dict) else u.as_dict() for u in utilities], ucounts=ucounts, domain=all_candidates, cmap=cmap, ) # ---- grade side: compose a GradeProfile over the same candidates/counts ---- self._grade_profile = GradeProfile( [g if isinstance(g, dict) else g.as_dict() for g in grade_maps], grades, gcounts=self.ucounts, 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() # ------------------------------------------------------------------ # Normalizations (override: carry the grades along, return UtilGradeProfile) # ------------------------------------------------------------------
[docs] def normalize_by_range(self): """Return a new profile with each utility normalized by range.""" return UtilGradeProfile( [u.normalize_by_range() for u in self._utilities], [g.as_dict() for g in self._grades], self.grades, ucounts=self.ucounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
[docs] def normalize_by_standard_score(self): """Return a new profile with each utility normalized by standard score.""" return UtilGradeProfile( [u.normalize_by_standard_score() for u in self._utilities], [g.as_dict() for g in self._grades], self.grades, ucounts=self.ucounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
# ------------------------------------------------------------------ # Conversions # ------------------------------------------------------------------
[docs] def to_utility_profile(self): """Return a :class:`UtilityProfile` with just the utility data.""" return UtilityProfile( [u.as_dict() for u in self._utilities], ucounts=self.ucounts, domain=self.candidates, cmap=self.cmap, )
[docs] def to_grade_profile(self): """Return a :class:`GradeProfile` with just the grade data.""" return GradeProfile( [g.as_dict() for g in self._grades], self.grades, gcounts=self.ucounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
[docs] def to_ranking_profile(self): """Return a :class:`ProfileWithTies` with rankings derived from utilities.""" return ProfileWithTies( [u.ranking() for u in self._utilities], rcounts=self.ucounts, candidates=self.candidates, cmap=self.cmap, )
[docs] def to_pref_grade_profile(self): """Return a :class:`PrefGradeProfile` with rankings derived from utilities.""" from pref_voting.pref_grade_profile import PrefGradeProfile return PrefGradeProfile( [u.ranking() for u in self._utilities], [g.as_dict() for g in self._grades], self.grades, rcounts=self.ucounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
# ------------------------------------------------------------------ # Candidate manipulation (lockstep: utilities and grades together) # ------------------------------------------------------------------
[docs] def remove_candidates(self, cands_to_ignore): """Return a new profile with the specified candidates removed.""" new_candidates = [c for c in self.candidates if c not in cands_to_ignore] new_utils = [ {c: u(c) for c in new_candidates if u.has_utility(c)} for u in self._utilities ] new_grades = [ {c: g(c) for c in new_candidates if g.has_grade(c)} for g in self._grades ] return UtilGradeProfile( new_utils, new_grades, self.grades, ucounts=self.ucounts, candidates=new_candidates, cmap={c: self.cmap[c] for c in new_candidates if c in self.cmap}, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )
# ------------------------------------------------------------------ # Display # ------------------------------------------------------------------
[docs] def display(self, cmap=None, show_totals=False): """Display the profile as an ASCII table showing utilities and grades.""" _cmap = cmap if cmap is not None else self.cmap if show_totals: headers = ( [""] + self.ucounts + [SEPARATING_LINE] + ["Sum U", "Sum G", "Median G"] ) tbl = [ [_cmap[c]] + [ f"{u(c):.4g} [{self.gmap[g(c)]}]" if u.has_utility(c) else "" for u, g in zip(self._utilities, self._grades) ] + [SEPARATING_LINE] + [self.util_sum(c), self.sum(c), self.median(c)] for c in self.candidates ] else: headers = [""] + self.ucounts tbl = [ [_cmap[c]] + [ f"{u(c):.4g} [{self.gmap[g(c)]}]" if u.has_utility(c) else "" for u, g in zip(self._utilities, self._grades) ] for c in self.candidates ] print(tabulate(tbl, headers=headers))
[docs] def visualize_grades(self): """Visualize grade distributions as a stacked bar plot.""" self.to_grade_profile().visualize()
# ------------------------------------------------------------------ # Serialization # ------------------------------------------------------------------
[docs] def description(self): """Return Python code that recreates this profile.""" return ( f"UtilGradeProfile(\n" f" {[u.as_dict() for u in self._utilities]},\n" f" {[g.as_dict() for g in self._grades]},\n" f" {self.grades},\n" f" ucounts={self.ucounts},\n" f" candidates={self.candidates},\n" f")" )
[docs] def as_dict(self): """Return a dictionary representation of the profile.""" return { "candidates": self.candidates, "utilities": [u.as_dict() for u in self._utilities], "grade_maps": [g.as_dict() for g in self._grades], "grades": self.grades, "ucounts": self.ucounts, "cmap": self.cmap, "gmap": self.gmap, }
def __getstate__(self): return self.as_dict() def __setstate__(self, state): self.__init__( state["utilities"], state["grade_maps"], state["grades"], ucounts=state["ucounts"], candidates=state["candidates"], cmap=state.get("cmap"), gmap=state.get("gmap"), ) # ------------------------------------------------------------------ # Dunder methods # ------------------------------------------------------------------ def __eq__(self, other): if not isinstance(other, UtilGradeProfile): return False return ( self.candidates == other.candidates and self.grades == other.grades and all( u1.as_dict() == u2.as_dict() for u1, u2 in zip(self._utilities, other._utilities) ) and all( g1.as_dict() == g2.as_dict() for g1, g2 in zip(self._grades, other._grades) ) and self.ucounts == other.ucounts ) __hash__ = None def __add__(self, other): assert self.candidates == other.candidates assert self.grades == other.grades return UtilGradeProfile( [u.as_dict() for u in self._utilities] + [u.as_dict() for u in other._utilities], [g.as_dict() for g in self._grades] + [g.as_dict() for g in other._grades], self.grades, ucounts=self.ucounts + other.ucounts, candidates=self.candidates, cmap=self.cmap, gmap=self.gmap, grade_order=self.grade_order if self.use_grade_order else None, )