Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/votekit/cleaning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
condense_rank_profile,
remove_and_condense_rank_profile,
remove_cand_rank_profile,
remove_repeat_cands_and_condense_rank_profile,
remove_repeat_cands_rank_profile,
)
from .score_ballots_cleaning import remove_cand_score_ballot
Expand All @@ -16,6 +17,7 @@
__all__ = [
"clean_rank_profile",
"remove_repeat_cands_rank_profile",
"remove_repeat_cands_and_condense_rank_profile",
"remove_cand_rank_profile",
"condense_rank_profile",
"remove_and_condense_rank_profile",
Expand Down
59 changes: 59 additions & 0 deletions src/votekit/cleaning/rank_profiles_cleaning.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,65 @@ def remove_repeat_cands_rank_profile(
)


def remove_repeat_cands_and_condense_rank_profile(
profile: RankProfile,
remove_empty_ballots: bool = True,
remove_zero_weight_ballots: bool = True,
retain_original_candidate_list: bool = True,
) -> CleanedRankProfile:
"""
Remove repeated candidates from each ranking and condense the resulting rankings.

A candidate's first appearance is retained and subsequent appearances are removed. Empty
ranking positions created by that removal, as well as pre-existing empty positions, are then
condensed. Ballots with no repeated candidates and only trailing empty positions are considered
unaltered because their expressed ranking does not change.

Args:
profile (RankProfile): Profile to clean.
remove_empty_ballots (bool, optional): Whether to remove ballots with no ranking after
cleaning. Defaults to True.
remove_zero_weight_ballots (bool, optional): Whether to remove zero-weight ballots.
Defaults to True.
retain_original_candidate_list (bool, optional): Whether to retain the original profile's
candidate list. Defaults to True.

Returns:
CleanedRankProfile: A cleaned ``RankProfile``.
"""

cleaned_profile = clean_rank_profile(
profile,
lambda ranking: condense_ranking_row(remove_repeat_cands_from_ranking_row(ranking)),
remove_empty_ballots,
remove_zero_weight_ballots,
retain_original_candidate_list,
)

assert profile.max_ranking_length is not None
ranking_cols = [f"Ranking_{i}" for i in range(1, profile.max_ranking_length + 1)]
ranking_df = profile.df[ranking_cols]
additional_unaltr_idxs = {
i
for i in cleaned_profile.nonempty_altr_idxs
if tuple(ranking_df.loc[i])
== remove_repeat_cands_from_ranking_row(tuple(ranking_df.loc[i]))
and _is_equiv_to_condensed(ranking_df.loc[i]) # type: ignore[arg-type]
Comment on lines +239 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to its own function that can call _is_equiv_to_condensed internally. Follows the pattern of other functions that condense ballots and clarifies what ballots are altered versus unaltered via your cleaning function.

}

return CleanedRankProfile(
df=cleaned_profile.df,
candidates=cleaned_profile.candidates,
max_ranking_length=cleaned_profile.max_ranking_length,
parent_profile=profile,
df_index_column=cleaned_profile.df_index_column,
no_wt_altr_idxs=cleaned_profile.no_wt_altr_idxs,
no_rank_altr_idxs=cleaned_profile.no_rank_altr_idxs,
nonempty_altr_idxs=cleaned_profile.nonempty_altr_idxs.difference(additional_unaltr_idxs),
unaltr_idxs=cleaned_profile.unaltr_idxs | additional_unaltr_idxs,
)


def remove_cand_from_ranking_row(
removed: Candidate | CandidateList,
ranking_tup: tuple[frozenset, ...],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest

from votekit.ballot import RankBallot
from votekit.cleaning import (
condense_rank_profile,
remove_repeat_cands_and_condense_rank_profile,
remove_repeat_cands_rank_profile,
)
from votekit.pref_profile import CleanedRankProfile, RankProfile


def test_remove_repeated_candidates_and_condense():
profile = RankProfile(
ballots=[
RankBallot(ranking=[{"A"}, {"A"}, {"B"}, {"C"}]),
RankBallot(ranking=[{"A", "C"}, {"C"}, frozenset(), {"B"}]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a case where the repeated candidate is in a tie. Not just the first instance of a candidate within a ranking.

]
)

cleaned = remove_repeat_cands_and_condense_rank_profile(profile)

assert isinstance(cleaned, CleanedRankProfile)
assert cleaned.parent_profile == profile
with pytest.warns(UserWarning, match="Grouping the ballots of a CleanedRankProfile"):
grouped = cleaned.group_ballots()
assert grouped.ballots == (
RankBallot(ranking=[{"A"}, {"B"}, {"C"}], weight=1),
RankBallot(ranking=[{"A", "C"}, {"B"}], weight=1),
)
assert cleaned.nonempty_altr_idxs == {0, 1}
assert cleaned.unaltr_idxs == set()


def test_combined_cleaning_matches_sequential_cleaning():
profile = RankProfile(ballots=[RankBallot(ranking=[{"A"}, {"A", "B"}, frozenset(), {"C"}])])

combined = remove_repeat_cands_and_condense_rank_profile(profile)
sequential = condense_rank_profile(remove_repeat_cands_rank_profile(profile))

assert combined.ballots == sequential.ballots
assert combined.candidates == sequential.candidates


def test_trailing_empty_positions_are_considered_unaltered():
profile = RankProfile(
ballots=[
RankBallot(ranking=[{"A"}, {"B"}, frozenset(), frozenset()]),
RankBallot(ranking=[{"A"}, frozenset(), {"B"}, frozenset()]),
]
)

cleaned = remove_repeat_cands_and_condense_rank_profile(profile)

assert cleaned.unaltr_idxs == {0}
assert cleaned.nonempty_altr_idxs == {1}