Remove ballots with candidate(s) cleaning function and update CleanedRankProfile index tracking - #389
Conversation
peterrrock2
left a comment
There was a problem hiding this comment.
Looking good so far!
There was a problem hiding this comment.
Fixtures
Test all attrs for CleanProfile independently
Test chaining
Test idempotent
There was a problem hiding this comment.
Added idempotent test that confirmed the result is the same after another application of the function. Added chaining test pin that remove ballots with a candidate and remove candidates from a ballot do not modify each other's results. All the attributes are tested for CleanProfile. Can split into a separate test for each attribute if desired.
There was a problem hiding this comment.
Great! We should also make sure to check steps in the chain robustly here. So doing more of
def test_remove_ballots_with_different_candidates_chaining(profile_no_ties):
first = remove_ballots_with_cand_rank_profile("A", profile_no_ties)
assert first.parent_profile is profile_no_ties
assert list(first.df.index) == first.df_index_column == [1, 2]
assert first.unaltr_idxs == {0, 1, 2, 3}
second = remove_ballots_with_cand_rank_profile("B", first)
assert second.parent_profile is first
assert list(second.df.index) == second.df_index_column == [2]
assert second.unaltr_idxs == {1, 2}
for cleaned in (first, second):
assert cleaned.no_rank_altr_idxs == set()
assert cleaned.no_wt_altr_idxs == set()
assert cleaned.nonempty_altr_idxs == set()which checks not only the profille after two rounds of cleaning, but also checks the parent and that the unalter_indxs of the child refer to the immediate parent (this is very much in the vein of dotting t's and crossing i's).
The idempotence tests are good (removing "A" twice), but we should aim to cover all cases.
| no_rank_altr_idxs = { | ||
| idx for idx, c in zip(idxs, cleaned_rows) if all(x == tilde or x == empty for x in c) | ||
| } | ||
| no_rank_altr_idxs = no_rank_altr_idxs - unaltr_idxs |
There was a problem hiding this comment.
Document change in PR description, and update the doc string in the CleanProfile class. Please add examples in that doc string so we can more easily determine the meaning of these.
TODO:
Add issue where we use a dataclass to descriptively partition the possible state space for these alterations -- users should make the decision on what they car about
example
@dataclass(slots = true)
class CleaningIndexDeltas:
dropped: set or list or tuple --> entire row removed index does not exist in child
remove_empty_ranking: TypedDict { trailing_only: <object>, internal_only: <object>, both_trailing_and_internal: <object>}
subset_previous_ballot: TypedDict {strict_subset_no_gaps ([A, B, {C, D}, E] -> [B, {C,D}]), strict_subset_gaps, weak_subset ([A, B, {C,D}, E] -> [B, C])
... and so onThis is a loose sketch
There was a problem hiding this comment.
I added an example to the CleanedRankProfile class and updated the docstring to reflect the current paradigm. Will add an issue.
| A removed ballot's ranking is considered empty after cleaning and recorded in the | ||
| ``no_rank_altr_idxs`` of the returned ``CleanedRankProfile``. |
There was a problem hiding this comment.
The current paradigm (which is not great) records dropped ballots as gaps in the index (compared to the parent profile)
There was a problem hiding this comment.
Updated the docstring to reflect dropped ballots as gaps in the index. I pointed to that dropped empty ranking and zero weight ballots are recorded in the same manner.
| with that integer candidate. | ||
| """ | ||
| if not isinstance(profile, RankProfile): | ||
| raise ProfileError("Profile must be a RankProfile.") |
There was a problem hiding this comment.
| raise ProfileError("Profile must be a RankProfile.") | |
| raise TypeError("Profile must be a RankProfile.") |
There was a problem hiding this comment.
Replaced all instances of ProfileError with TypeError within rank_profiles_cleaning.py. 1 test was broken and fixed with TypeError.
| if isinstance(removed, Candidate) and not isinstance(removed, bool): | ||
| removed = [removed] | ||
| elif isinstance(removed, list): | ||
| if any(not isinstance(cand, (str, int)) or isinstance(cand, bool) for cand in removed): | ||
| raise TypeError("Candidates must be strings or integers within removed.") | ||
| else: | ||
| raise TypeError("removed must be a str/int candidate or a list of candidates.") |
There was a problem hiding this comment.
I think that we have a function that checks if something is a valid candidate that we should use here
There was a problem hiding this comment.
Replaced with _validate_candidate_names
| ranking_cols = [f"Ranking_{i}" for i in range(1, profile.max_ranking_length + 1)] | ||
| ballots_to_remove = profile._df[ranking_cols].isin(cand_ids).any(axis=1) | ||
| cleaned_df = profile.df[~ballots_to_remove] | ||
| removed_ballot_idxs = set(profile.df[ballots_to_remove].index) |
There was a problem hiding this comment.
| removed_ballot_idxs = set(profile.df[ballots_to_remove].index) |
There was a problem hiding this comment.
The removed ballots are not considered no_rank_altr_idxs but are just dropped, no alteration of their ranking nor weight. Their removal is accounted for in the difference between the parent profile's index and the cleaned profile's index. This applies to removed empty ranking and zero weight ballots, too.
CleanedRankProfile index tracking
| respect to ``parent_profile.df``. A ballot has no ranking after cleaning if its ranking | ||
| contains only empty or tilde sets, and it had at least one valid candidated ranked | ||
| before cleaning. |
There was a problem hiding this comment.
| respect to ``parent_profile.df``. A ballot has no ranking after cleaning if its ranking | |
| contains only empty or tilde sets, and it had at least one valid candidated ranked | |
| before cleaning. | |
| respect to ``parent_profile.df``. A ballot index can only be a member for the `no_rank_altr_idxs` | |
| set if, after cleaning, the ballot consists of only elements of the form `frozenset()` (the | |
| empty frozen set) or `frozenset({'~'})` (the set of the special character "tilde"), and it is not | |
| identical to itself before applying the cleaning operation. |
There was a problem hiding this comment.
Updated to follow this rule: "If a ballot, as a result of cleaning, does not belong to unaltr_idxs and contains at least one ranking position with frozenset(<SET_OF_CANDS>) where <SET_OF_CANDS> is a non-empty set of valid candidate identifiers, then that ballot goes in nonempy_altr_idxs."
| def _validate_candidate_names( | ||
| candidates: Iterable[Candidate], source: Optional[object] = None, attribute: str = "candidates" | ||
| ) -> None: |
There was a problem hiding this comment.
We should use some more descriptive types her to avoid this:
source_description = f"{source.__class__.__name__}.{attribute}" if source else f"{attribute}"c.f. our Slack messages
There was a problem hiding this comment.
Added a dataclass for a object with attribute and TypeAlias for variable names to describe what we expect for the context of error messages when validating candidate's name.
| unaltr_idxs = {idx for idx, (o, c) in zip(idxs, zip(orig_rows, cleaned_rows)) if o == c} | ||
| no_rank_altr_idxs = {idx for idx, c in zip(idxs, cleaned_rows) if all(x == tilde for x in c)} | ||
| no_rank_altr_idxs = { | ||
| idx for idx, c in zip(idxs, cleaned_rows) if all(x == tilde or x == empty for x in c) |
There was a problem hiding this comment.
Nit:
| idx for idx, c in zip(idxs, cleaned_rows) if all(x == tilde or x == empty for x in c) | |
| idx for idx, cln_row in zip(idxs, cleaned_rows) if all(cand_set == tilde or cand_set == empty for cand_set in cln_row) |
| remove_empty_ballots: bool = True, | ||
| remove_zero_weight_ballots: bool = True, | ||
| retain_original_candidate_list: bool = True, | ||
| retain_original_candidate_list: bool = False, |
There was a problem hiding this comment.
We should leave this as True
There was a problem hiding this comment.
Agreed. Reverted. Will add an issue around visiting the default values for these cleaning flags.
There was a problem hiding this comment.
How to partition the ballots after cleaning:
- If a ballot is identical before and after cleaning, it goes in
unaltr_idxs. - If a ballot, as a result of cleaning, goes from having positive weight to no weight, then it goes in
no_wt_altr_idxs. - If a ballot, as a result of cleaning, contains only
frozenset()andfrozenset({'~'}), and is not a member ofunaltr_idxs, then it goes inno_rank_altr_idxs - If a ballot, as a result of cleaning, does not belong to
unaltr_idxsand contains at least one ranking position withfrozenset(<SET_OF_CANDS>)where<SET_OF_CANDS>is a non-empty set of valid candidate identifiers, then that ballot goes innonempy_altr_idxs.
There was a problem hiding this comment.
Make sure to add unit tests for exactly these cases if they are attainable for each cleaning function. In particular, for each defined cleaning function, try to suss out all of the edge cases for the last 2 bullets.
There was a problem hiding this comment.
Updated the docstring for CleanedRankProfile within cleaned_pref_profile.py to capture this partition.
This means that _is_equiv_to_condensed and _is_equiv_to_remove_and_condensed are unnecessary functions to add back additional_unaltr_idxs that are not identical rankings before and after cleaning but their information is not meaningfully changed. For example, a ballot that is ["A", "B", {}] where rank position 3 is empty would be ["A", "B"] after condensing. Candidates have not changed their ranking but rank position 3's empty set has been replaced with ``frozenset({'~'})(special character tilde). Prior,_is_equiv_to_condensed` would add this ballot's index to `unaltr_idxs` but with the updated strict paradigm, this ballot has been altered and should be a member of the `nonempty_altr_idxs` set.
Updated cleaning functions tests to test edge cases for nonempty_altr_idxs and no_rank_altr_idxs.
…s and tests, _validate_candidate_names uses a dataclass to specify error message context
peterrrock2
left a comment
There was a problem hiding this comment.
Nearly done! A couple of small things, and we should be good to go
| source: object | ||
| attribute: str | ||
|
|
||
| def __post_init(self): |
There was a problem hiding this comment.
Bug: the post-init will never run
| def __post_init(self): | |
| def __post_init(self)__: |
There was a problem hiding this comment.
Good catch! Thanks! Confirmed it runs now.
There was a problem hiding this comment.
We need to update the doc strings for condense_rank_profile and remove_and_condense_rank_profile: they both still say that condensing trailing empty positions leaves a ballot "unaltered" and we changed things so that replacing trailing empty sets with padding now puts the index in nonempty_altr_idxs
There was a problem hiding this comment.
Updated doc strings for condense_rank_profile and remove_and_condense_rank_profile to differentiate trailing empty sets that go in nonempty_altr_idxs and only empty sets or a mix with tilde sets that goes in no_rank_altr_idxs. I explained trailing empty sets are replaced with frozenset({'~'}) while empty sets between ranking positions with candidate sets are removed and the rest of the ranking is shifted up.
| def __post_init(self): | ||
| if not isinstance(self.attribute, str): | ||
| raise TypeError("Attribute must be a string.") | ||
| if not hasattr(self.source, self.attribute): |
There was a problem hiding this comment.
I am realizing that we should use getattr_static here since we might have some uninitialized values. The getattr_static function checks for declared slots, which is all we need here.
There was a problem hiding this comment.
getattr_static would cover the case where a source is a __slots__ class object and its attribute has not been initialized but is a declared slot and attribute of the object. I updated __post_init__ to use inspect.getattr_static after checking if hasattr is True. SlateCandMap stores its parent (BlocSlateConfig) as a proxy and its attributes are accessible through hasattr not getattr_static.
There was a problem hiding this comment.
Great! We should also make sure to check steps in the chain robustly here. So doing more of
def test_remove_ballots_with_different_candidates_chaining(profile_no_ties):
first = remove_ballots_with_cand_rank_profile("A", profile_no_ties)
assert first.parent_profile is profile_no_ties
assert list(first.df.index) == first.df_index_column == [1, 2]
assert first.unaltr_idxs == {0, 1, 2, 3}
second = remove_ballots_with_cand_rank_profile("B", first)
assert second.parent_profile is first
assert list(second.df.index) == second.df_index_column == [2]
assert second.unaltr_idxs == {1, 2}
for cleaned in (first, second):
assert cleaned.no_rank_altr_idxs == set()
assert cleaned.no_wt_altr_idxs == set()
assert cleaned.nonempty_altr_idxs == set()which checks not only the profille after two rounds of cleaning, but also checks the parent and that the unalter_indxs of the child refer to the immediate parent (this is very much in the vein of dotting t's and crossing i's).
The idempotence tests are good (removing "A" twice), but we should aim to cover all cases.
…ourceWithAttribute check slots
peterrrock2
left a comment
There was a problem hiding this comment.
I think that there are just a couple of doc string updates for this and then we can call this good. Great work!
There was a problem hiding this comment.
Propagate the doc string update for remove_null_ballot
There was a problem hiding this comment.
remove_null_ballot arg all have the same doc string now. Updated the condense cleaning functions docstrings to be a bulleted list along with some spelling fixes.
Closes #381
Summary
remove_ballots_with_cand_rank_profileis added as a cleaning function to remove ballots containing a certain candidate or one from a set of candidates. Currently, specific candidate(s) can be removed from a ballot, but this supports workflows where ballots with an invalid write-in marker like"overvote"or"undervote"can be removed entirely.Adding it surfaced that
CleanedRankProfile's index sets can place ballots in the wrong buckets.New paradigm for index tracking
Before:
_iterate_and_clean_ranking_tuplesproduced a first-pass classification, and then individual cleaners "corrected" it.condense_rank_profileandremove_and_condense_rank_profileeach ran a equivalence check(
_is_equiv_to_condensed,_is_equiv_for_remove_and_condense) overnonempty_altr_idxsand moved matching indices back intounaltr_idxs. This equivalence check tried to move ballots that were meaningfully the same before and after cleaning intounaltr_idxsbut they were altered even if the candidates rankings positions were maintained.Now: Now all four sets are derived in one place, from a direct comparison of each ballot's ranking tuple before and after cleaning:
unaltr_idxsorig_row == cleaned_row, identical before and afterno_rank_altr_idxsfrozenset()and/orfrozenset("~"), minusunaltr_idxsnonempty_altr_idxsno_wt_altr_idxsBoth equivalence check functions were deleted.
Things to note about this change:
unaltr_idxsincludes dropped ballots, because dropping is not altering. A ballot removed for being null or zero weight was already inunaltr_idxsbecause they are not touched by the cleaning function. To find what was dropped, compare the cleaned profile's index against the parent's index.remove_ballots_with_cand_rank_profiledoes not callclean_rank_profilebecause it acts more as a filter, then cleaner. It alters no ranking, and so reports every parent index as unaltered.Changes
remove_ballots_with_cand_rank_profileis added torank_profiles_cleaning.py. Ballots are matched using the candidates' integer IDs in the internaldf. Removed ballots are treated the same as dropped null-ranking and zero-weight ballots, i.e. gaps in the index relative to the parent profile.no_rank_altr_idxsnow includes ballots where every rank slot isfrozenset()orfrozenset("~"). This covers ballots where the cleaning function removed all ranked candidates and left empty frozensets behind.unaltr_idxsis subtracted fromno_rank_altr_idxs, so ballots that were already empty are distinguished from ballots that became empty through cleaning.unaltr_idxsnow also covers ballots that were dropped but never modified. This already included dropped null-ranking and zero-weight ballots, and now includes the ballots removed byremove_ballots_with_cand_rank_profile._is_equiv_to_condensedand_is_equiv_for_remove_and_condenseare removed._validate_candidate_namestakes acontextargument, either aSourceWithAttributedataclass or a plain variable name instead of separatesource/attributeparameters. So, callers can get an informative error message about the source of their candidates. This cascades throughballot.py,pref_profile.py, and the bloc-slate config modules.Testing
test_remove_ballots_with_cand_rank_profile.pyadded, coveringremovedcandidates with and without ties, chaining withremove_cand, and the index-set bookkeeping.test_clean_ranked_profile.pyandtest_remove_cand_ranked_profile.pyupdated for theno_rank_altr_idxs/unaltr_idxschanges.test_condense_ranked_profile.pyupdated for the removal of the condense equivalence functions.test_collections.py,test_bloc_slate_config.py,test_RankBallot.py, andtest_common_utils.pyupdated for the_validate_candidate_namessignature change.