-
Notifications
You must be signed in to change notification settings - Fork 33
feat: add profile subtraction utility #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -574,3 +574,159 @@ def sum_profiles(profiles: Sequence[PreferenceProfile]) -> PreferenceProfile: | |
| f"Cannot sum profiles of type {type(profiles[0]).__name__}. " | ||
| "List can only contain RankProfiles or ScoreProfiles." | ||
| ) | ||
|
|
||
|
|
||
| def subtract_profiles( | ||
| minuend: PreferenceProfile, subtrahend: PreferenceProfile | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to |
||
| ) -> PreferenceProfile: | ||
| """ | ||
| Subtract the ballot weights of one profile from another, matched by | ||
| identical ballot rankings (or scores). | ||
|
|
||
| Profiles with the same ballot rankings but differing weights have their | ||
| weights subtracted, leaving the voter set of the minuend untouched. | ||
|
|
||
| Args: | ||
| minuend (PreferenceProfile): The profile being subtracted from. | ||
| subtrahend (PreferenceProfile): The profile whose weights are subtracted. | ||
|
|
||
| Returns: | ||
| PreferenceProfile: A new profile with the subtracted ballot weights. | ||
|
|
||
| Raises: | ||
| TypeError: Both profiles must be of the same type, RankProfile or ScoreProfile. | ||
| ValueError: A ballot weight would become negative. | ||
| """ | ||
|
|
||
| from votekit.pref_profile.pref_profile import RankProfile, ScoreProfile | ||
|
|
||
| if type(minuend) is not type(subtrahend): | ||
| raise TypeError( | ||
| f"Both profiles must be of the same type. " | ||
| f"Got {type(minuend).__name__} and {type(subtrahend).__name__}." | ||
| ) | ||
|
|
||
| if isinstance(minuend, RankProfile): | ||
| return _subtract_rank_profiles(minuend, subtrahend) | ||
|
|
||
| if isinstance(minuend, ScoreProfile): | ||
| return _subtract_score_profiles(minuend, subtrahend) | ||
|
|
||
| raise TypeError( | ||
| f"Cannot subtract profiles of type {type(minuend).__name__}. " | ||
| "Only RankProfiles or ScoreProfiles are supported." | ||
| ) | ||
|
|
||
|
|
||
| def _subtract_rank_profiles(minuend: "RankProfile", subtrahend: "RankProfile") -> "RankProfile": | ||
| """Helper function for subtract_profiles that subtracts RankProfiles.""" | ||
|
|
||
| from votekit.pref_profile.pref_profile import RankProfile | ||
|
|
||
| candidates = list(set().union(*[set(profile.candidates) for profile in [minuend, subtrahend]])) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A ballot can only be subtracted if it exists in the Likewise, we would want to define what we do if a ballot to be subtracted does not exist in the |
||
| max_ranking_length = max( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Building off the |
||
| [ | ||
| profile.max_ranking_length | ||
| for profile in [minuend, subtrahend] | ||
| if profile.max_ranking_length is not None | ||
| ] | ||
| ) | ||
|
|
||
| # Pad both profiles to the same ranking length | ||
| padded_dfs = [] | ||
| for profile in [minuend, subtrahend]: | ||
| assert profile.max_ranking_length is not None | ||
| curr_df = profile.df.copy() | ||
| for i in range(profile.max_ranking_length, max_ranking_length): | ||
| curr_df.insert( | ||
| len(curr_df.columns), | ||
| f"Ranking_{i + 1}", | ||
| pd.Series([frozenset("~")] * len(curr_df), dtype=object, index=curr_df.index), | ||
| ) | ||
| padded_dfs.append(curr_df) | ||
|
Comment on lines
+635
to
+646
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to pad the profiles. We only care if ballots from |
||
|
|
||
| minuend_df, subtrahend_df = padded_dfs | ||
| ranking_cols = [f"Ranking_{i + 1}" for i in range(max_ranking_length)] | ||
|
|
||
| # Align by ranking and subtract weights | ||
| subtrahend_weights = subtrahend_df.groupby(ranking_cols, dropna=False)["Weight"].sum() | ||
| minuend_grouped = minuend_df.groupby(ranking_cols, dropna=False) | ||
|
Comment on lines
+652
to
+653
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a |
||
| new_rows = [] | ||
| for key, group in minuend_grouped: | ||
| key = key[0] if isinstance(key, tuple) and len(key) == 1 else key | ||
| weight = group["Weight"].sum() - subtrahend_weights.get(key, 0) | ||
| if weight < 0: | ||
| raise ValueError( | ||
| f"Cannot subtract profiles: ballot weight would become negative for ranking {key}." | ||
| ) | ||
|
Comment on lines
+658
to
+661
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A negative weight could be an error if the user wants to enforce the |
||
| voter_set = set().union(*group["Voter Set"]) | ||
| ranking = { | ||
| col: key[i] if i < len(key) else frozenset("~") for i, col in enumerate(ranking_cols) | ||
| } | ||
| new_rows.append( | ||
| { | ||
| **ranking, | ||
| "Weight": weight, | ||
| "Voter Set": voter_set, | ||
| } | ||
| ) | ||
|
|
||
| # Ballots present only in the subtrahend do not appear in the result | ||
| if new_rows: | ||
| new_df = pd.DataFrame(new_rows)[ranking_cols + ["Weight", "Voter Set"]] | ||
| else: | ||
| new_df = pd.DataFrame(columns=ranking_cols + ["Weight", "Voter Set"], dtype=object) | ||
| new_df.index.name = "Ballot Index" | ||
|
|
||
| return RankProfile( | ||
| candidates=candidates, | ||
| df=new_df, | ||
| max_ranking_length=max_ranking_length, | ||
| ) | ||
|
|
||
|
|
||
| def _subtract_score_profiles(minuend: "ScoreProfile", subtrahend: "ScoreProfile") -> "ScoreProfile": | ||
| """Helper function for subtract_profiles that subtracts ScoreProfiles.""" | ||
|
|
||
| from votekit.pref_profile.pref_profile import ScoreProfile | ||
|
|
||
| total_cand = set().union(*[set(profile.candidates) for profile in [minuend, subtrahend]]) | ||
| minuend_df = minuend.df.copy() | ||
| subtrahend_df = subtrahend.df.copy() | ||
| for df in (minuend_df, subtrahend_df): | ||
| for cand in total_cand - set(df.columns) - {"Weight", "Voter Set"}: | ||
| df[cand] = [np.nan] * len(df) | ||
|
|
||
| cand_cols = sort_candidates_pseudo_lexicographically(total_cand) | ||
| # NaN keys never compare equal, so fill missing scores with a sentinel | ||
| # before grouping so identical ballots match across profiles. | ||
| sentinel = -np.inf | ||
| subtrahend_filled = subtrahend_df.copy() | ||
| subtrahend_filled[cand_cols] = subtrahend_filled[cand_cols].fillna(sentinel) | ||
| minuend_filled = minuend_df.copy() | ||
| minuend_filled[cand_cols] = minuend_filled[cand_cols].fillna(sentinel) | ||
|
|
||
| subtrahend_weights = subtrahend_filled.groupby(cand_cols, dropna=False)["Weight"].sum() | ||
| minuend_grouped = minuend_filled.groupby(cand_cols, dropna=False) | ||
| new_rows = [] | ||
| for key, group in minuend_grouped: | ||
| key = key[0] if isinstance(key, tuple) and len(key) == 1 else key | ||
| weight = group["Weight"].sum() - subtrahend_weights.get(key, 0) | ||
| if weight < 0: | ||
| raise ValueError( | ||
| f"Cannot subtract profiles: ballot weight would become negative for scores {key}." | ||
| ) | ||
| voter_set = set().union(*group["Voter Set"]) | ||
| scores = {cand: (np.nan if v == sentinel else v) for cand, v in zip(cand_cols, key)} | ||
| new_rows.append({**scores, "Weight": weight, "Voter Set": voter_set}) | ||
|
|
||
| if new_rows: | ||
| new_df = pd.DataFrame(new_rows)[cand_cols + ["Weight", "Voter Set"]] | ||
| else: | ||
| new_df = pd.DataFrame(columns=cand_cols + ["Weight", "Voter Set"], dtype=object) | ||
| new_df.index.name = "Ballot Index" | ||
|
|
||
| return ScoreProfile( | ||
| candidates=sort_candidates_pseudo_lexicographically(total_cand), | ||
| df=new_df, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import pytest | ||
|
|
||
| from votekit.ballot import RankBallot, ScoreBallot | ||
| from votekit.pref_profile import RankProfile, ScoreProfile | ||
| from votekit.pref_profile.utils import subtract_profiles | ||
|
|
||
|
|
||
| def test_subtract_rank_profiles_weights(): | ||
| minuend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=5), | ||
| RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), weight=3, voter_set={"Chris"}), | ||
| ], | ||
| candidates=["A", "B", "C", "D"], | ||
| max_ranking_length=3, | ||
| ) | ||
| subtrahend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), | ||
| ], | ||
| candidates=["A", "B", "C", "D"], | ||
| max_ranking_length=3, | ||
| ) | ||
| result = minuend - subtrahend | ||
| weights = {b.ranking: b.weight for b in result.ballots} | ||
| assert weights[(frozenset({"A"}), frozenset({"B"}), frozenset({"C"}))] == 3 | ||
| assert weights[(frozenset({"A", "B"}), frozenset(), frozenset({"D"}))] == 3 | ||
|
|
||
|
|
||
| def test_subtract_rank_profiles_retains_voter_set(): | ||
| minuend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=4, voter_set={"Chris", "Sam"}), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| subtrahend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=1), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| result = minuend - subtrahend | ||
| assert result.ballots[0].voter_set == {"Chris", "Sam"} | ||
|
|
||
|
|
||
| def test_subtract_rank_profiles_negative_weight_raises(): | ||
| minuend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=1), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| subtrahend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=3), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| with pytest.raises(ValueError, match="negative"): | ||
| minuend - subtrahend | ||
|
|
||
|
|
||
| def test_subtract_rank_profiles_different_lengths(): | ||
| minuend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=5), | ||
| ], | ||
| candidates=["A", "B", "C"], | ||
| max_ranking_length=3, | ||
| ) | ||
| subtrahend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=2), | ||
| ], | ||
| candidates=["A", "B", "C"], | ||
| max_ranking_length=2, | ||
| ) | ||
| result = minuend - subtrahend | ||
| assert result.ballots[0].weight == 3 | ||
| assert result.max_ranking_length == 3 | ||
|
|
||
|
|
||
| def test_subtract_score_profiles_weights(): | ||
| minuend = ScoreProfile( | ||
| ballots=[ | ||
| ScoreBallot(scores={"A": 2, "B": 2}, weight=4), | ||
| ScoreBallot(scores={"A": 1, "C": 3}, weight=2), | ||
| ], | ||
| candidates=["A", "B", "C"], | ||
| ) | ||
| subtrahend = ScoreProfile( | ||
| ballots=[ | ||
| ScoreBallot(scores={"A": 2, "B": 2}, weight=1), | ||
| ], | ||
| candidates=["A", "B", "C"], | ||
| ) | ||
| result = minuend - subtrahend | ||
| weights = {tuple(sorted(b.scores.items())): b.weight for b in result.ballots} | ||
| assert weights[(("A", 2), ("B", 2))] == 3 | ||
| assert weights[(("A", 1), ("C", 3))] == 2 | ||
|
|
||
|
|
||
| def test_subtract_score_profiles_negative_weight_raises(): | ||
| minuend = ScoreProfile( | ||
| ballots=[ | ||
| ScoreBallot(scores={"A": 2}, weight=1), | ||
| ], | ||
| candidates=["A"], | ||
| ) | ||
| subtrahend = ScoreProfile( | ||
| ballots=[ | ||
| ScoreBallot(scores={"A": 2}, weight=3), | ||
| ], | ||
| candidates=["A"], | ||
| ) | ||
| with pytest.raises(ValueError, match="negative"): | ||
| minuend - subtrahend | ||
|
|
||
|
|
||
| def test_subtract_profiles_mixed_types_raises(): | ||
| score_profile = ScoreProfile( | ||
| ballots=[ScoreBallot(scores={"A": 2}, weight=2)], | ||
| candidates=["A"], | ||
| ) | ||
| rank_profile = RankProfile( | ||
| ballots=[RankBallot(ranking=({"A"},), weight=2)], | ||
| candidates=["A"], | ||
| max_ranking_length=1, | ||
| ) | ||
| with pytest.raises(TypeError, match="same type"): | ||
| subtract_profiles(rank_profile, score_profile) | ||
|
|
||
|
|
||
| def test_subtract_profiles_function(): | ||
| minuend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=5), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| subtrahend = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=({"A"}, {"B"}), weight=2), | ||
| ], | ||
| candidates=["A", "B"], | ||
| max_ranking_length=2, | ||
| ) | ||
| result = subtract_profiles(minuend, subtrahend) | ||
| assert result.ballots[0].weight == 3 | ||
|
|
||
|
|
||
| def test_subtract_profiles_unknown_type_raises(): | ||
| class FakeProfile: | ||
| pass | ||
|
|
||
| with pytest.raises(TypeError, match="Cannot subtract"): | ||
| subtract_profiles(FakeProfile(), FakeProfile()) # type: ignore[arg-type] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minuendandsubtrahendare not intuitive to those without a math background.base_profileandsubtracted_profileswould be clearer.