feat: add profile subtraction utility - #387
Conversation
Add subtract_profiles() and the __sub__ operator to RankProfile and ScoreProfile. Ballot weights are subtracted for identical rankings (or scores), the minuend's voter sets are retained, and a ValueError is raised if any weight would become negative. This enables inverse queries to search_profile_for_rank_pattern. Closes mggg#384
|
(copy of comment on #383) Hey @aryansk! Thank you so much for contributing to VoteKit! It's wonderful to see our work being used by people outside of our community. If you happen to have a moment, I would love to hear more about how you found the package and what you are using it for so that we can better fit the needs of our users. You may have noticed that we made our latest release of VoteKit around two weeks ago. Since then, I have been preoccupied with some other projects and missed the latest set of PRs that came in. This looks like a great start on this issue, and one of the other maintainers will be adding a review with some additional feedback shortly. I also saw you mention that you were assisted by Codex when working on this issue. Thank you for including that information. As an academic lab, we value transparency of process, and knowing this sort of thing is genuinely helpful. I think that, until this point, we have not been clear about our preferences for how contributors use AI tools when submitting work for review on this project. I hope the following makes this clear, but I want to state it explicitly anyway: the recent updates to this policy are not an indictment of any of the work that you have done here. As a maintainer of a small OSS package, it's always heartening to see more people trying to become a part of the community, and I am truly grateful for the work that you have put in. Additional notes (this section is a form that I am including on several recent PRs) Since there have been so many new contributors in the last couple of weeks (a wonderful problem to have), I thought it important to update the "Use of AI Tools" section of the Contributing.md on our main branch to clarify what we currently consider to be best-practices for our lab. To be clear, this this is not an indictment of the use of any AI tools or a statement about the work presented here (indeed, it would be a bit illogical to find anyone at issue with this policy before it was explicitly stated!). I include it here only because this is 1. a recent update in a document people infrequently visit and 2. an effort to clearly communicate our expectations around contributing to this project. I'll include the exact snippet here so you don't have to go through the trouble of finding it yourself:
Thank you again for your contribution, and I hope that we'll see more from you soon! -Peter |
graceg571
left a comment
There was a problem hiding this comment.
Thanks for contributing this PR! Subtracting profiles can take many different forms:
- set difference: subtract only rankings and ignore its weigh. If there's a ranking match between the base profile and the subtracted profile(s), the ranking is removed by default.
- reduce by weight: subtract ballots by weight. The ranking's weight will be reduced by the weight value of the matching ranking in the subtracted profile. An error would be thrown when a ballot does not exist or goes negative weight because weight cannot be reduced from a missing ballot and a negative weight is not a valid weight reduction.
- "normal" ballot subtraction: the base profile is the bag of ballots to be removed/subtracted by the subtracted profile(s). A ballot in the subtracted profile found in the base profile is subtracted (or removed from the bag) till the weight of the subtracted ranking's weight is consumed or the base ranking's weight is exhausted (i.e. zero weight). So, zero weight rankings are removed from the profile by default.
We would like to start with the last ballot subtraction type, and my review comments reflect that decision. Let me know if you want to keep working on this!
|
|
||
|
|
||
| def subtract_profiles( | ||
| minuend: PreferenceProfile, subtrahend: PreferenceProfile |
There was a problem hiding this comment.
minuend and subtrahend are not intuitive to those without a math background. base_profile and subtracted_profiles would be clearer.
|
|
||
|
|
||
| def subtract_profiles( | ||
| minuend: PreferenceProfile, subtrahend: PreferenceProfile |
There was a problem hiding this comment.
Similar to sum_profiles, the subtrahend could be a list of profiles or a single profile, and the profiles can be summed together prior to subtraction
|
|
||
| from votekit.pref_profile.pref_profile import RankProfile | ||
|
|
||
| candidates = list(set().union(*[set(profile.candidates) for profile in [minuend, subtrahend]])) |
There was a problem hiding this comment.
A ballot can only be subtracted if it exists in the minuend profile. Therefore, the total list of candidates is always the same as minuend's candidates.
Likewise, we would want to define what we do if a ballot to be subtracted does not exist in the minuend profile.
| from votekit.pref_profile.pref_profile import RankProfile | ||
|
|
||
| candidates = list(set().union(*[set(profile.candidates) for profile in [minuend, subtrahend]])) | ||
| max_ranking_length = max( |
There was a problem hiding this comment.
Building off the candidates comment, the max_ranking_length should be the minuend profile as that's the "ground truth" we remove ballots from.
| # 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) |
There was a problem hiding this comment.
No need to pad the profiles. We only care if ballots from subtrahend profile match ballots from minuend profile. Ballots with the same rankings will be equivalent with/without padding.
| subtrahend_weights = subtrahend_df.groupby(ranking_cols, dropna=False)["Weight"].sum() | ||
| minuend_grouped = minuend_df.groupby(ranking_cols, dropna=False) |
There was a problem hiding this comment.
There's a group_ballots instance method for rank and score profiles.
| if weight < 0: | ||
| raise ValueError( | ||
| f"Cannot subtract profiles: ballot weight would become negative for ranking {key}." | ||
| ) |
There was a problem hiding this comment.
A negative weight could be an error if the user wants to enforce the subtrahend profile being a subset of the minuend profile. For instance, if we were subtracting by weight, a negative weight would indicate subtraction has gone beyond the weight allocated to that ballot. For this function, the subtrahend does not need to be a subset of the minuend, and instead the subtrahend is the hand that removes ballots from the minuend bag. Based on this analogy, if the hand goes to remove a ballot that no longer exists in the bag, then nothing is removed. Therefore, a ballot's weight should be constrained to no less than 0 and removed from the profile. We can have a parameter to state whether we remove those zero weight ballots and have the default be to remove them.
Problem
Profiles can be combined with
+/sum_profiles, but there is no inverse operation. Withsearch_profile_for_rank_patternavailable, queries like "ballots where a candidate pair is separated by at least x ranks" need profile subtraction.Change
subtract_profiles(minuend, subtrahend)inpref_profile/utils.py.__sub__toRankProfileandScoreProfile.ValueErroris raised if any weight would become negative.Testing
tests/pref_profile/utils/test_subtract_profiles.py(9 tests) covering weight subtraction, voter-set retention, negative-weight errors, mixed-length rankings, mixed types, and the standalone function.tests/pref_profile/suite passes (170 tests).Closes #384