Skip to content

Commit 8d7b921

Browse files
authored
Merge pull request #378 from mggg/enhancement/faster-mentions
Enhancement/faster mentions
2 parents 56e9641 + a24aa9f commit 8d7b921

2 files changed

Lines changed: 245 additions & 9 deletions

File tree

src/votekit/utils/common_utils.py

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import random
55
import warnings
66
from itertools import permutations
7-
from typing import TYPE_CHECKING, Iterable, Literal, Optional, Sequence
7+
from typing import TYPE_CHECKING, Any, Iterable, Literal, Optional, Sequence, cast
88

99
import numpy as np
1010
import pandas as pd
@@ -388,18 +388,23 @@ def first_place_votes(
388388
)
389389

390390

391-
def mentions(
391+
def _mentions_from_ballots(
392392
profile: RankProfile,
393393
) -> dict[Candidate, float]:
394394
"""
395-
Calculates total mentions for all candidates in a ``RankProfile``.
395+
Calculates total mentions for all candidates by iterating the profile's ballot list.
396396
397397
Args:
398398
profile (RankProfile): RankProfile of ballots.
399399
400400
Returns:
401401
dict[Candidate, float]:
402402
Dictionary mapping candidates to mention totals (values).
403+
Candidates can be strings, integers, or mix of both.
404+
405+
Raises:
406+
TypeError: If profile is not a RankProfile.
407+
TypeError: If any ballot has no ranking.
403408
"""
404409
from votekit.pref_profile import RankProfile
405410

@@ -409,13 +414,109 @@ def mentions(
409414
for ballot in profile.ballots:
410415
if ballot.ranking is None:
411416
raise TypeError("Ballots must have rankings.")
412-
else:
413-
for s in ballot.ranking:
414-
for cand in s:
415-
mentions[cand] += float(ballot.weight)
417+
# a candidate mentioned only on zero-weight ballots is absent from profile.candidates,
418+
# and the ballot contributes nothing anyway
419+
if ballot.weight == 0:
420+
continue
421+
for s in ballot.ranking:
422+
for cand in s:
423+
mentions[cand] += float(ballot.weight)
416424
return mentions
417425

418426

427+
def _mentions_from_df(profile: RankProfile) -> dict[Candidate, float]:
428+
"""
429+
Calculates total mentions for all candidates from the profile df, without materializing
430+
ballots.
431+
432+
Args:
433+
profile (RankProfile): RankProfile of ballots.
434+
435+
Returns:
436+
dict[Candidate, float]:
437+
Dictionary mapping candidates to mention totals (values).
438+
Candidates can be strings, integers, or mix of both.
439+
440+
Raises:
441+
TypeError: If profile is not a RankProfile.
442+
TypeError: If any ballot has no ranking, i.e. its df row is entirely "~" cells.
443+
"""
444+
from votekit.pref_profile import RankProfile
445+
446+
if not isinstance(profile, RankProfile):
447+
raise TypeError("Profile must be of type RankProfile.")
448+
449+
assert profile.max_ranking_length is not None
450+
451+
ranking_cols = [f"Ranking_{i}" for i in range(1, profile.max_ranking_length + 1)]
452+
453+
tilde = frozenset({"~"})
454+
455+
# no ranking columns with ballots present means every ballot has ranking=None
456+
if len(profile.df) and not ranking_cols:
457+
raise TypeError("Ballots must have rankings.")
458+
459+
# positional index so duplicate df index labels cannot break the weight lookup below
460+
rank_sets = cast(Any, profile.df[ranking_cols].reset_index(drop=True).stack())
461+
462+
mask = rank_sets.map(lambda s: isinstance(s, frozenset) and bool(s) and s != tilde)
463+
464+
# a row of only "~" cells is the df encoding of ranking=None; match the ballot path's
465+
# error. A present-but-empty frozenset ranking is allowed in both paths.
466+
is_tilde = rank_sets.map(lambda s: s == tilde)
467+
if len(is_tilde) and is_tilde.groupby(level=0).all().any():
468+
raise TypeError("Ballots must have rankings.")
469+
470+
rank_sets = rank_sets[mask]
471+
exploded = rank_sets.explode()
472+
473+
if exploded.empty:
474+
return {c: 0.0 for c in profile.candidates}
475+
476+
weights = profile.df["Weight"].to_numpy()[exploded.index.get_level_values(0)]
477+
478+
totals = pd.Series(weights).groupby(exploded.to_numpy(), sort=False).sum()
479+
480+
# float() normalizes np.float64 so both mentions paths return the same types
481+
return {c: float(totals.get(c, 0.0)) for c in profile.candidates}
482+
483+
484+
def mentions(profile: RankProfile) -> dict[Candidate, float]:
485+
"""
486+
Calculates total mentions for all candidates in a ``RankProfile``.
487+
488+
Every position that lists a candidate contributes the full ballot weight, so each member of
489+
a tied position receives the full weight, and a candidate ranked in multiple positions of
490+
one ballot is counted once per position.
491+
492+
Computes from the profile's ballot list when it is already materialized and from the
493+
underlying df otherwise; both give identical results.
494+
495+
Args:
496+
profile (RankProfile): RankProfile of ballots.
497+
498+
Returns:
499+
dict[Candidate, float]:
500+
Dictionary mapping candidates to mention totals (values).
501+
Candidates can be strings, integers, or mix of both.
502+
503+
Raises:
504+
TypeError: If profile is not a RankProfile.
505+
TypeError: If any ballot has no ranking.
506+
"""
507+
from votekit.pref_profile import RankProfile
508+
509+
if not isinstance(profile, RankProfile):
510+
raise TypeError("Profile must be of type RankProfile.")
511+
512+
# NOTE: If the ballots are materialized, then iterating through the ballots directly tends
513+
# to be faster than using the DataFrame.
514+
if "ballots" in profile.__dict__:
515+
return _mentions_from_ballots(profile)
516+
517+
return _mentions_from_df(profile)
518+
519+
419520
def borda_scores(
420521
profile: RankProfile,
421522
borda_max: Optional[int] = None,

tests/utils/test_common_utils.py

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import random
2+
from fractions import Fraction
13
from itertools import permutations
4+
from pathlib import Path
25
from typing import Literal, cast
36

7+
import pandas as pd
48
import pytest
59

610
from votekit.ballot import RankBallot, ScoreBallot
@@ -25,6 +29,9 @@
2529
tiebroken_ranking,
2630
validate_score_vector,
2731
)
32+
from votekit.utils.common_utils import _mentions_from_ballots, _mentions_from_df
33+
34+
CSV_DIR = Path(__file__).resolve().parents[1] / "data" / "csv"
2835

2936
profile_no_ties = RankProfile(
3037
ballots=(
@@ -42,6 +49,14 @@
4249
)
4350
)
4451

52+
profile_with_duplicates = RankProfile(
53+
ballots=(
54+
RankBallot(ranking=tuple(map(frozenset, [{"A"}, {"B"}, {"B"}])), weight=1),
55+
RankBallot(ranking=tuple(map(frozenset, [{"A"}, {"B"}, {"C"}])), weight=1 / 2),
56+
RankBallot(ranking=tuple(map(frozenset, [{"B"}, {"B"}, {"B"}])), weight=3),
57+
)
58+
)
59+
4560
profile_with_missing = RankProfile(
4661
ballots=(
4762
RankBallot(ranking=tuple(map(frozenset, [{"A", "B"}, {"D"}])), weight=1),
@@ -246,13 +261,133 @@ def test_fpv_errors():
246261
first_place_votes(cast(RankProfile, ScoreProfile(ballots=(ScoreBallot(scores={"A": 3}),))))
247262

248263

249-
def test_mentions():
264+
def test_mentions_from_ballots():
265+
correct = {"A": 9 / 2, "B": 9 / 2, "C": 7 / 2}
266+
test = _mentions_from_ballots(profile_no_ties)
267+
assert correct == test
268+
assert isinstance(test["A"], float)
269+
270+
271+
def test_mentions_from_ballots_with_ties():
272+
correct = {"A": 9 / 2, "B": 9 / 2, "C": 7 / 2}
273+
test = _mentions_from_ballots(profile_with_ties)
274+
assert correct == test
275+
assert isinstance(test["A"], float)
276+
277+
278+
def test_mentions_from_ballots_with_duplicates():
279+
correct = {"A": 3 / 2, "B": 23 / 2, "C": 1 / 2}
280+
test = _mentions_from_ballots(profile_with_duplicates)
281+
assert correct == test
282+
assert isinstance(test["A"], float)
283+
284+
285+
def test_mentions_from_df():
286+
correct = {"A": 9 / 2, "B": 9 / 2, "C": 7 / 2}
287+
test = _mentions_from_df(profile_no_ties)
288+
assert correct == test
289+
assert isinstance(test["A"], float)
290+
291+
292+
def test_mentions_from_df_with_ties():
250293
correct = {"A": 9 / 2, "B": 9 / 2, "C": 7 / 2}
251-
test = mentions(profile_no_ties)
294+
test = _mentions_from_df(profile_with_ties)
295+
assert correct == test
296+
assert isinstance(test["A"], float)
297+
298+
299+
def test_mentions_from_df_with_duplicates():
300+
correct = {"A": 3 / 2, "B": 23 / 2, "C": 1 / 2}
301+
test = _mentions_from_df(profile_with_duplicates)
252302
assert correct == test
253303
assert isinstance(test["A"], float)
254304

255305

306+
@pytest.mark.slow
307+
def test_fast_and_slow_mentions_are_same():
308+
profile = cast(RankProfile, RankProfile.from_csv(CSV_DIR / "albany_profile.csv"))
309+
assert _mentions_from_ballots(profile) == _mentions_from_df(profile)
310+
311+
312+
def test_mentions_zero_weight_ballots():
313+
# "A" appears only on a zero-weight ballot, so it is not in profile.candidates; both
314+
# mentions paths must skip it rather than raise KeyError
315+
profile = RankProfile(
316+
ballots=(
317+
RankBallot(ranking=tuple(map(frozenset, [{"A"}])), weight=0),
318+
RankBallot(ranking=tuple(map(frozenset, [{"B"}])), weight=2),
319+
)
320+
)
321+
correct = {"B": 2.0}
322+
assert _mentions_from_ballots(profile) == correct
323+
assert _mentions_from_df(profile) == correct
324+
325+
326+
def test_mentions_from_df_returns_python_floats():
327+
assert all(type(v) is float for v in _mentions_from_df(profile_no_ties).values())
328+
329+
330+
def test_mentions_no_ranking_ballot_raises_in_both_paths():
331+
# an unranked ballot materializes with ranking=None, so both paths must raise the same error
332+
profile = RankProfile(ballots=(RankBallot(weight=2), RankBallot(ranking=({"A"},), weight=1)))
333+
with pytest.raises(TypeError, match="Ballots must have rankings."):
334+
_mentions_from_df(profile)
335+
with pytest.raises(TypeError, match="Ballots must have rankings."):
336+
_mentions_from_ballots(profile)
337+
338+
339+
def test_mentions_all_unranked_profile_raises_in_both_paths():
340+
# every ballot has ranking=None, so the profile has no ranking columns at all
341+
profile = RankProfile(ballots=(RankBallot(weight=2),))
342+
with pytest.raises(TypeError, match="Ballots must have rankings."):
343+
_mentions_from_df(profile)
344+
with pytest.raises(TypeError, match="Ballots must have rankings."):
345+
_mentions_from_ballots(profile)
346+
347+
348+
def test_mentions_empty_frozenset_ranking_consistent():
349+
# a present-but-empty frozenset ranking is not ranking=None; neither path should raise
350+
profile = RankProfile(ballots=(RankBallot(ranking=(frozenset(),), weight=1),))
351+
assert _mentions_from_df(profile) == {}
352+
assert _mentions_from_ballots(profile) == {}
353+
354+
355+
def test_mentions_from_df_duplicate_ballot_index():
356+
# profiles built from a df keep the given index; duplicate labels must not break the df path
357+
df = RankProfile(ballots=(RankBallot(ranking=({"A"}, {"B"}), weight=1),)).df
358+
profile = RankProfile(df=pd.concat([df, df]), candidates=("A", "B"), max_ranking_length=2)
359+
correct = {"A": 2.0, "B": 2.0}
360+
assert _mentions_from_df(profile) == correct
361+
assert _mentions_from_ballots(profile) == correct
362+
363+
364+
def _random_mentions_profile(rng: random.Random) -> RankProfile:
365+
# samples every dimension that has produced a path divergence: ties, duplicate candidates,
366+
# short ballots, zero and Fraction weights, mixed str/int candidates
367+
cands = ["A", "B", 1, 2, "C"][: rng.randint(2, 5)]
368+
ballots = []
369+
for _ in range(rng.randint(1, 8)):
370+
pool = [rng.choice(cands) for _ in range(rng.randint(1, len(cands)))]
371+
ranking = []
372+
while pool:
373+
n = rng.randint(1, min(2, len(pool)))
374+
ranking.append(frozenset(pool[:n]))
375+
pool = pool[n:]
376+
weight = rng.choice([0, 1, 3, 1 / 2, Fraction(1, 3)])
377+
ballots.append(RankBallot(ranking=tuple(ranking), weight=weight))
378+
# sometimes omit the explicit candidate list so profile.candidates can exclude candidates
379+
# that appear only on zero-weight ballots
380+
if rng.random() < 0.5:
381+
return RankProfile(ballots=tuple(ballots), max_ranking_length=len(cands))
382+
return RankProfile(ballots=tuple(ballots), candidates=cands, max_ranking_length=len(cands))
383+
384+
385+
@pytest.mark.parametrize("seed", range(25))
386+
def test_mentions_paths_stay_in_sync(seed):
387+
profile = _random_mentions_profile(random.Random(seed))
388+
assert _mentions_from_ballots(profile) == _mentions_from_df(profile)
389+
390+
256391
def test_mentions_errors():
257392
with pytest.raises(TypeError, match="Profile must be of type RankProfile"):
258393
mentions(cast(RankProfile, ScoreProfile(ballots=(ScoreBallot(scores={"A": 3}),))))

0 commit comments

Comments
 (0)