-
Notifications
You must be signed in to change notification settings - Fork 33
feat: truncate ranked ballots at sentinel candidates #383
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 |
|---|---|---|
|
|
@@ -288,6 +288,83 @@ def remove_cand_rank_profile( | |
| ) | ||
|
|
||
|
|
||
| def truncate_ranking_row( | ||
| removed: Candidate | CandidateList, | ||
| ranking_tup: tuple[frozenset, ...], | ||
| ) -> tuple[frozenset, ...]: | ||
| """ | ||
| Truncate a ranking at the first position containing a specified candidate or marker. | ||
|
|
||
| The matching position and every position below it are replaced with trailing ``~`` | ||
| placeholders so that the ranking keeps its original width in a profile dataframe. | ||
|
|
||
| Args: | ||
| removed (Candidate | list[Candidate]): Candidate or list of candidates or markers at | ||
| which to truncate. | ||
| ranking_tup (tuple): Ranking to truncate. | ||
|
|
||
| Returns: | ||
| tuple: Ranking truncated at the first matching position. | ||
|
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. Update docstring to |
||
| """ | ||
| if isinstance(removed, Candidate): | ||
| removed = [removed] | ||
|
|
||
| removed_set = set(removed) | ||
| out: list[frozenset] = [] | ||
|
|
||
| for cand_set in ranking_tup: | ||
| if cand_set.isdisjoint(removed_set): | ||
|
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. Currently, this will truncate ballots where a candidate within the |
||
| out.append(cand_set) | ||
| continue | ||
|
|
||
| out.extend([frozenset("~")] * (len(ranking_tup) - len(out))) | ||
| break | ||
|
Comment on lines
+315
to
+321
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. [nit] Suggest to make an explicit |
||
|
|
||
| return tuple(out) | ||
|
|
||
|
|
||
| def truncate_rank_profile( | ||
| removed: Candidate | CandidateList, | ||
|
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. Would move away from |
||
| profile: RankProfile, | ||
| remove_empty_ballots: bool = True, | ||
| remove_zero_weight_ballots: bool = True, | ||
| retain_original_candidate_list: bool = True, | ||
| ) -> CleanedRankProfile: | ||
| """ | ||
| Truncate ranked ballots at the first position containing a specified candidate or marker. | ||
|
|
||
| This is useful for cleaning CVR data where values such as ``"overvote"`` or ``"undervote"`` | ||
| terminate the meaningful portion of a ballot. The matching position and all lower-ranked | ||
| positions are removed. Ballots without a matching value are retained unchanged. | ||
|
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. You're right that this function is especially useful for cleaning CVR data with ballot error markers or end-of-ballot markers but it doesn't need to be captured in the docstring. And then add "Wrapper for clean_rank_profile that does some extra processing to ensure the candidate list is handled correctly." |
||
|
|
||
| Args: | ||
| removed (Candidate | list[Candidate]): Candidate, marker, or list of candidates and | ||
| markers at which to truncate. | ||
| profile (RankProfile): Profile to truncate. | ||
| remove_empty_ballots (bool, optional): Whether or not to remove ballots with no ranking | ||
| after truncation. Defaults to True. | ||
| remove_zero_weight_ballots (bool, optional): Whether or not to remove zero-weight ballots. | ||
| Defaults to True. | ||
| retain_original_candidate_list (bool, optional): Whether or not to retain the original | ||
| candidate list. Defaults to True. | ||
|
|
||
| Returns: | ||
| CleanedRankProfile: A cleaned ``RankProfile``. | ||
|
|
||
| Raises: | ||
| ProfileError: Profile must only contain ranked ballots. | ||
| """ | ||
| cleaned_profile = clean_rank_profile( | ||
| profile, | ||
| partial(truncate_ranking_row, removed), | ||
| remove_empty_ballots, | ||
| remove_zero_weight_ballots, | ||
| retain_original_candidate_list, | ||
| ) | ||
|
|
||
|
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. Add candidate list handling here for |
||
| return cleaned_profile | ||
|
|
||
|
|
||
| def condense_ranking_row( | ||
| ranking_tup: tuple, | ||
| ) -> tuple: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import pytest | ||
|
|
||
| from votekit.ballot import RankBallot, ScoreBallot | ||
| from votekit.cleaning import truncate_rank_profile | ||
| from votekit.pref_profile import CleanedRankProfile, ProfileError, RankProfile, ScoreProfile | ||
|
|
||
| profile = RankProfile( | ||
| ballots=[ | ||
| RankBallot(ranking=[{"A"}, {"overvote"}, {"B"}, {"C"}], weight=1), | ||
| RankBallot(ranking=[{"A"}, {"B"}, {"C"}], weight=2), | ||
| RankBallot(ranking=[{"undervote"}, {"C"}], weight=3), | ||
| RankBallot(ranking=[{"A"}, {"B"}], weight=0), | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def test_truncate_rank_profile_at_candidate_or_marker(): | ||
| cleaned_profile = truncate_rank_profile(["overvote", "undervote"], profile) | ||
|
|
||
| assert isinstance(cleaned_profile, CleanedRankProfile) | ||
| assert cleaned_profile.parent_profile == profile | ||
| assert cleaned_profile.ballots == ( | ||
| RankBallot(ranking=[{"A"}], weight=1), | ||
| RankBallot(ranking=[{"A"}, {"B"}, {"C"}], weight=2), | ||
| ) | ||
| assert cleaned_profile.no_rank_altr_idxs == {2} | ||
| assert cleaned_profile.nonempty_altr_idxs == {0} | ||
| assert cleaned_profile.unaltr_idxs == {1, 3} | ||
| assert cleaned_profile.no_wt_altr_idxs == set() | ||
|
|
||
|
|
||
| def test_truncate_rank_profile_can_retain_empty_and_zero_weight_ballots(): | ||
|
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. This test does not cover retaining empty ballots. Only zero weight ballots.
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. The |
||
| cleaned_profile = truncate_rank_profile( | ||
| "overvote", | ||
| profile, | ||
| remove_empty_ballots=False, | ||
| remove_zero_weight_ballots=False, | ||
| ) | ||
|
|
||
| assert cleaned_profile.ballots == ( | ||
| RankBallot(ranking=[{"A"}], weight=1), | ||
| RankBallot(ranking=[{"A"}, {"B"}, {"C"}], weight=2), | ||
| RankBallot(ranking=[{"undervote"}, {"C"}], weight=3), | ||
| RankBallot(ranking=[{"A"}, {"B"}], weight=0), | ||
| ) | ||
|
|
||
|
|
||
| def test_truncate_rank_profile_requires_rank_profile(): | ||
| score_profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 1})]) | ||
|
|
||
| with pytest.raises(ProfileError, match="Profile must be a RankProfile."): | ||
| truncate_rank_profile("overvote", score_profile) # 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.
Would update name to be something like
truncate_at_cand_ranking_rowortruncate_ranking_row_at_candto be more specific about the type of truncation. Same comment fortruncate_rank_profile.