Source code for scorio.aggregate.vote

r"""Vote-based answer aggregation for test-time scaling.

Given a pool of candidate answers per question, these rules return a single
answer by *counting*: each candidate casts a vote for its answer and the answer
with the largest total wins. They differ only in how a candidate's vote is
weighted and which candidates are allowed to vote:

- plain **majority vote** -- unit weights (self-consistency);
- **weighted** vote -- weight equal to the raw score;
- **softmax** vote -- weight ``exp(score / T)`` (temperature ``T``);
- **rank-weighted** (Borda) vote -- weight from the score's ordinal rank;
- **logit-weighted** vote -- a threshold-shifted log-odds weight that can go
  negative;
- **filtered** vote -- only the top-scoring candidates are allowed to vote.

All but ``majority_vote`` consume per-candidate reward/verifier/confidence
scores (higher is better). Every rule groups candidates by answer equality and
returns the winning group's answer, with ties broken by earliest appearance.

Methods
-------
- ``majority_vote``: the most frequent answer (self-consistency; Wang et al.,
  2023).
- ``weighted_majority_vote``: the answer whose group maximizes the aggregated
  score (verifier-weighted voting; Li et al., 2023).
- ``softmax_weighted_vote``: temperature-softmax-weighted vote bridging majority
  vote and Best-of-N (CISC; Taubenfeld et al., 2025).
- ``rank_weighted_vote``: Borda / rank-weighted vote, invariant to monotone
  rescaling of the scores (Kang et al., 2025).
- ``logit_weighted_vote``: threshold-shifted log-odds weighted vote where
  low-scoring candidates vote against their answer (Kuang et al., 2025).
- ``filtered_vote``: keep the top-scoring candidates, then (weighted) vote
  (DeepConf; Fu et al., 2025; Cobbe et al., 2021).

All operate on ``(N,)`` (one question) or ``(M, N)`` (a batch of questions) and
return only the selected answer(s); measuring the accuracy of a selection is
left to :mod:`scorio.eval` (see the subpackage docstring).
"""

from __future__ import annotations

import math
from typing import Any

from ._base import (
    _finalize,
    _finalize_index,
    _is_valid,
    _keep_count,
    _normalize,
    _pack,
    _plurality,
    _valid_indices,
)

__all__ = [
    "majority_vote",
    "weighted_majority_vote",
    "softmax_weighted_vote",
    "rank_weighted_vote",
    "logit_weighted_vote",
    "filtered_vote",
]


def _row_majority(ans_row: Any) -> tuple[Any, int]:
    """Most frequent valid answer in a single row; ties broken by first appearance."""
    tally: dict[Any, list[int]] = {}  # answer -> [count, first_index]
    for j, a in enumerate(ans_row):
        if not _is_valid(a):
            continue
        if a not in tally:
            tally[a] = [0, j]
        tally[a][0] += 1
    if not tally:
        return None, -1
    best = min(tally, key=lambda a: (-tally[a][0], tally[a][1]))
    return best, tally[best][1]


def _row_weighted(ans_row: Any, score_row: Any, aggregate: str) -> tuple[Any, int]:
    """Answer whose group maximizes summed/mean score; ties broken by first appearance."""
    groups: dict[Any, dict[str, Any]] = {}
    for j, a in enumerate(ans_row):
        if not _is_valid(a):
            continue
        s = float(score_row[j])
        g = groups.get(a)
        if g is None:
            groups[a] = {"sum": s, "count": 1, "first": j, "best_s": s, "best_i": j}
        else:
            g["sum"] += s
            g["count"] += 1
            if s > g["best_s"]:
                g["best_s"] = s
                g["best_i"] = j
    if not groups:
        return None, -1

    def weight(a: Any) -> float:
        g = groups[a]
        return g["sum"] / g["count"] if aggregate == "mean" else g["sum"]

    best = min(groups, key=lambda a: (-weight(a), groups[a]["first"]))
    return best, groups[best]["best_i"]


[docs] def majority_vote(answers: Any, *, return_index: bool = False) -> Any: r""" Majority vote (self-consistency) over a candidate pool. Selects the answer that appears most frequently among the sampled candidates for each question. This is the *self-consistency* decoding rule: sample several chain-of-thought completions, marginalize over the reasoning paths, and keep the most common final answer. References: Wang, X., Wei, J., Schuurmans, D., Le, Q., Chi, E., Narang, S., Chowdhery, A., & Zhou, D. (2023). Self-Consistency Improves Chain of Thought Reasoning in Language Models. *ICLR 2023*, *arXiv:2203.11171*. https://arxiv.org/abs/2203.11171 Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Entries that are ``None``, ``""`` or ``NaN`` are treated as unparsable and ignored. Answers are compared by equality, so any hashable label (string, int, canonicalized expression) works. return_index: If ``True``, also return a representative candidate index per question (the first occurrence of the winning answer). Returns: The selected answer for each question. This is a scalar for ``(N,)`` input or an ``(M,)`` object array for ``(M, N)`` input. With ``return_index``, a pair ``(selected, index)`` is returned, where ``index`` is ``-1`` for a question whose candidates are all unparsable. Formula: Let :math:`Z_{\alpha\cdot}` be the answers for question :math:`\alpha` and :math:`c_\alpha(z) = \sum_i \mathbb{1}[Z_{\alpha i} = z]`. Then .. math:: \hat z_\alpha = \operatorname*{arg\,max}_{z} c_\alpha(z), with ties broken by earliest appearance in the sample order. Examples: >>> answers = ["A", "B", "A", "C", "A"] >>> majority_vote(answers) 'A' >>> majority_vote(answers, return_index=True) ('A', 0) >>> batch = [["A", "B", "A"], ["X", "X", "Y"]] >>> majority_vote(batch).tolist() ['A', 'X'] """ Z, _, single = _normalize(answers) selected: list[Any] = [] indices: list[int] = [] for row in Z: a, idx = _row_majority(row) selected.append(a) indices.append(idx) if return_index: return _finalize(selected, single), _finalize_index(indices, single) return _finalize(selected, single)
[docs] def weighted_majority_vote( answers: Any, scores: Any, *, aggregate: str = "sum", return_index: bool = False, return_score: bool = False, ) -> Any: r""" Verifier-weighted majority vote over a candidate pool. Groups candidates by answer and selects the group with the largest aggregated score. With ``aggregate="sum"`` each answer is weighted by the total score of its candidates (so agreement *and* quality both help); with ``aggregate="mean"`` answers are ranked by average candidate quality. When the scores are a verifier's correctness probabilities this is the weighted self-consistency of Li et al. (2023). With ``aggregate="sum"``, unit weights recover plain :func:`majority_vote`. With ``aggregate="mean"``, unit weights give every nonempty answer group the same score, so the tie-break rule decides the result. References: Li, Y., Lin, Z., Zhang, S., Fu, Q., Chen, B., Lou, J.-G., & Chen, W. (2023). Making Language Models Better Reasoners with Step-Aware Verifier. *ACL 2023*, *arXiv:2206.02336*. https://arxiv.org/abs/2206.02336 Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Unparsable entries (``None``, ``""``, ``NaN``) are ignored. scores: ``(N,)`` or ``(M, N)`` array-like of per-candidate scores (reward-model / verifier scores; higher is better), same shape as ``answers``. aggregate: ``"sum"`` (default) or ``"mean"`` group aggregation. return_index: If ``True``, also return the index of the highest-scoring member of the winning group per question. return_score: If ``True``, also return the score of that representative candidate -- the best reward within the winning group (``NaN`` if no candidate has a valid answer). Note this is the representative's own score, not the aggregated group weight that decided the vote. Returns: The selected answer per question (scalar or ``(M,)`` object array). When ``return_index`` and/or ``return_score`` are set, a tuple ``(selected[, index][, score])`` is returned in that order. Formula: With group score :math:`W_\alpha(z)` the answer is .. math:: W_\alpha(z) = \sum_{i:\, Z_{\alpha i} = z} S_{\alpha i} \quad(\text{sum}), \qquad W_\alpha(z) = \frac{\sum_{i:\, Z_{\alpha i}=z} S_{\alpha i}} {\sum_i \mathbb{1}[Z_{\alpha i}=z]} \quad(\text{mean}), and :math:`\hat z_\alpha = \arg\max_z W_\alpha(z)`, ties broken by earliest appearance. Examples: >>> answers = ["A", "A", "A", "B"] >>> scores = [0.3, 0.3, 0.3, 0.85] >>> weighted_majority_vote(answers, scores) # sum: A=0.9 > B=0.85 'A' >>> weighted_majority_vote(answers, scores, aggregate="mean") # mean: A=0.3 < B=0.85 'B' >>> weighted_majority_vote(answers, scores, return_score=True) # best A-member reward ('A', 0.3) """ if aggregate not in ("sum", "mean"): raise ValueError(f"aggregate must be 'sum' or 'mean'; got {aggregate!r}.") Z, S, single = _normalize(answers, scores, require_scores=True) assert S is not None selected: list[Any] = [] indices: list[int] = [] sel_scores: list[float] = [] for row, srow in zip(Z, S, strict=True): a, idx = _row_weighted(row, srow, aggregate) selected.append(a) indices.append(idx) sel_scores.append(float(srow[idx]) if idx >= 0 else float("nan")) return _pack( selected, indices, sel_scores, single, return_index=return_index, return_score=return_score, )
def _row_softmax(ans_row: Any, score_row: Any, temperature: float) -> tuple[Any, int]: """Softmax-weighted plurality for one row; ties broken by first appearance.""" part = _valid_indices(ans_row) if not part: return None, -1 smax = max(float(score_row[j]) for j in part) w = {j: math.exp((float(score_row[j]) - smax) / temperature) for j in part} return _plurality(ans_row, part, lambda j: w[j], lambda j: float(score_row[j])) def _row_rank(ans_row: Any, score_row: Any, p: float) -> tuple[Any, int]: """Rank-weighted (Borda) plurality for one row; ties broken by first appearance.""" part = _valid_indices(ans_row) if not part: return None, -1 n = len(part) order = sorted(part, key=lambda j: (-float(score_row[j]), j)) if float(p).is_integer(): # Exact arbitrary-precision integer weights (n - t) ** p: no overflow and # exact tie detection, matching the package's integer-weight convention. pe = int(p) w: dict[int, float] = {j: (n - t) ** pe for t, j in enumerate(order)} else: # Non-integer p: ((n - t) / n) ** p is the paper's (n - t) ** p divided by # the per-question constant n ** p (argmax-invariant), and its (0, 1] base # cannot overflow the way int ** float can for large exponents. w = {j: ((n - t) / n) ** p for t, j in enumerate(order)} return _plurality(ans_row, part, lambda j: w[j], lambda j: float(score_row[j])) def _row_logit( ans_row: Any, score_row: Any, threshold: float, transform: str ) -> tuple[Any, int]: """Log-odds / linear threshold-shifted weighted plurality for one row.""" part = _valid_indices(ans_row) if not part: return None, -1 if transform == "logit": tb = math.log(threshold / (1.0 - threshold)) def weight_of(j: int) -> float: s = float(score_row[j]) return math.log(s / (1.0 - s)) - tb else: # "linear" def weight_of(j: int) -> float: return float(score_row[j]) - threshold return _plurality(ans_row, part, weight_of, lambda j: float(score_row[j])) def _row_filtered( ans_row: Any, score_row: Any, keep: Any, weighted: bool ) -> tuple[Any, int]: """Vote over the top-``keep`` scoring candidates for one row.""" part = _valid_indices(ans_row) if not part: return None, -1 k = _keep_count(keep, len(part)) kept = sorted(part, key=lambda j: (-float(score_row[j]), j))[:k] def weight_of(j: int) -> float: return float(score_row[j]) if weighted else 1.0 return _plurality(ans_row, kept, weight_of, lambda j: float(score_row[j])) def _run_vote( row_fn: Any, Z: Any, S: Any, single: bool, *, return_index: bool, return_score: bool, ) -> Any: """Apply a per-row score-aware vote helper across all rows and pack the output.""" selected: list[Any] = [] indices: list[int] = [] sel_scores: list[float] = [] for row, srow in zip(Z, S, strict=True): a, idx = row_fn(row, srow) selected.append(a) indices.append(idx) sel_scores.append(float(srow[idx]) if idx >= 0 else float("nan")) return _pack( selected, indices, sel_scores, single, return_index=return_index, return_score=return_score, )
[docs] def softmax_weighted_vote( answers: Any, scores: Any, *, temperature: float = 1.0, return_index: bool = False, return_score: bool = False, ) -> Any: r""" Temperature-softmax-weighted majority vote (CISC). A weighted vote in which each candidate's weight is a *softmax* of its score, :math:`\exp(S_i / T)`, rather than the raw score. The single temperature :math:`T > 0` continuously bridges the three basic rules: :math:`T \to \infty` flattens the weights to majority vote, while :math:`T \to 0` concentrates all weight on the highest-scoring candidate, recovering Best-of-N (exactly so when the top score is unique). This is the Confidence-Informed Self-Consistency (CISC) rule, where the score is the model's own confidence in each response. References: Taubenfeld, A., Sheffer, T., Ofek, E., Feder, A., Goldstein, A., Gekhman, Z., & Yona, G. (2025). Confidence Improves Self-Consistency in LLMs. *Findings of ACL 2025*, *arXiv:2502.06233* (Definition 3.1). https://arxiv.org/abs/2502.06233 Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Unparsable entries (``None``, ``""``, ``NaN``) are ignored. scores: ``(N,)`` or ``(M, N)`` array-like of per-candidate scores (higher is better), same shape as ``answers``. temperature: Softmax temperature :math:`T > 0`. Smaller sharpens toward Best-of-N; larger flattens toward majority vote (``float('inf')`` gives exactly majority vote). return_index: If ``True``, also return the highest-scoring member of the winning answer group per question (``-1`` if none are valid). return_score: If ``True``, also return the score of that representative candidate (``NaN`` if none are valid). Returns: The selected answer per question (scalar or ``(M,)`` object array). When ``return_index`` and/or ``return_score`` are set, a tuple ``(selected[, index][, score])`` is returned in that order. Formula: The paper's softmax weights :math:`\tilde c_i = \exp(S_{\alpha i}/T) / \sum_j \exp(S_{\alpha j}/T)` share a per-question denominator that cancels in the ``argmax``, so the selection is equivalently .. math:: \hat z_\alpha = \operatorname*{arg\,max}_z \sum_{i:\, Z_{\alpha i} = z} \exp\!\big(S_{\alpha i} / T\big), with ties broken by earliest appearance. (Implemented with the standard ``exp((S - max S) / T)`` shift for numerical stability, which leaves the ``argmax`` unchanged.) Examples: >>> answers = ["A", "A", "B"] >>> scores = [0.1, 0.2, 0.9] >>> softmax_weighted_vote(answers, scores, temperature=0.1) # sharp -> Best-of-N 'B' >>> softmax_weighted_vote(answers, scores, temperature=float("inf")) # -> majority 'A' """ if not (temperature > 0.0): raise ValueError(f"temperature must be > 0; got {temperature}.") Z, S, single = _normalize(answers, scores, require_scores=True) assert S is not None return _run_vote( lambda row, srow: _row_softmax(row, srow, temperature), Z, S, single, return_index=return_index, return_score=return_score, )
[docs] def rank_weighted_vote( answers: Any, scores: Any, *, p: float = 1.0, return_index: bool = False, return_score: bool = False, ) -> Any: r""" Rank-weighted (Borda) majority vote. A weighted vote that uses only the *ordinal rank* of the scores, not their magnitudes. Candidates are ranked by score (best first) and the rank-``t`` candidate (``t = 0`` is the best) casts a vote of weight :math:`(n - t)^p`. Because it depends only on the score ordering, the rule is invariant to any monotone rescaling of the scores -- a robustness the magnitude-based :func:`weighted_majority_vote` does not have. The exponent :math:`p \ge 0` interpolates between majority vote (``p = 0``, all weights equal) and Best-of-N (``p \to \infty``, the top candidate dominates); ``p = 1`` is the standard Borda count. Introduced as *self-certainty Borda voting*. References: Kang, Z., Zhao, X., & Song, D. (2025). Scalable Best-of-N Selection for Large Language Models via Self-Certainty. *NeurIPS 2025*, *arXiv:2502.18581*. https://arxiv.org/abs/2502.18581 Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Unparsable entries (``None``, ``""``, ``NaN``) are ignored. scores: ``(N,)`` or ``(M, N)`` array-like of per-candidate scores (higher is better), same shape as ``answers``. p: Non-negative Borda exponent. ``0`` recovers majority vote; ``1`` is standard Borda; larger values sharpen toward Best-of-N. return_index: If ``True``, also return the highest-scoring member of the winning answer group per question (``-1`` if none are valid). return_score: If ``True``, also return the score of that representative candidate (``NaN`` if none are valid). Returns: The selected answer per question (scalar or ``(M,)`` object array). When ``return_index`` and/or ``return_score`` are set, a tuple ``(selected[, index][, score])`` is returned in that order. Formula: Ranking the :math:`n` valid candidates for question :math:`\alpha` by descending score (ties by lowest index) and letting :math:`r_{\alpha i} \in \{1,\ldots,n\}` be candidate :math:`i`'s rank, .. math:: \hat z_\alpha = \operatorname*{arg\,max}_z \sum_{i:\, Z_{\alpha i} = z} (n - r_{\alpha i} + 1)^{p}, with ties broken by earliest appearance. Examples: >>> answers = ["A", "A", "B"] >>> scores = [0.30, 0.31, 0.99] >>> rank_weighted_vote(answers, scores, p=1.0) # Borda: A gets 2+1, B gets 3 'A' >>> rank_weighted_vote(answers, scores, p=0.0) # -> majority vote 'A' """ if not (p >= 0.0) or p == float("inf"): raise ValueError(f"p must be a finite non-negative number; got {p}.") Z, S, single = _normalize(answers, scores, require_scores=True) assert S is not None return _run_vote( lambda row, srow: _row_rank(row, srow, p), Z, S, single, return_index=return_index, return_score=return_score, )
[docs] def logit_weighted_vote( answers: Any, scores: Any, *, threshold: float = 0.5, transform: str = "logit", return_index: bool = False, return_score: bool = False, ) -> Any: r""" Threshold-shifted log-odds weighted majority vote. The Bayes-optimal aggregation of a generator's samples with a verifier's scores casts each candidate's vote with a weight that is a *log-likelihood ratio* of its score, approximated in closed form by a threshold-shifted transform. With ``transform="logit"`` the weight is :math:`\operatorname{logit}(S_i) - \operatorname{logit}(b)` (scores must be probabilities in :math:`(0, 1)`); with ``transform="linear"`` it is the simpler :math:`S_i - b`. Unlike :func:`weighted_majority_vote`, weights can be **negative**: candidates scoring below the threshold :math:`b` vote *against* their own answer, so an answer supported only by low-quality candidates can be beaten by a rarer but higher-quality one. References: Kuang, P., Wang, Y., Han, X., Liu, Y., Xu, K., & Wang, H. (2025). Optimal Aggregation of LLM and PRM Signals for Efficient Test-Time Scaling. *ICLR 2026*, *arXiv:2510.13918* (Theorem 3.2). https://arxiv.org/abs/2510.13918 Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Unparsable entries (``None``, ``""``, ``NaN``) are ignored. scores: ``(N,)`` or ``(M, N)`` array-like of per-candidate scores. For ``transform="logit"`` every valid score must lie strictly in ``(0, 1)`` (a verifier probability); for ``transform="linear"`` any real score is allowed. threshold: The calibrated decision threshold :math:`b`. For ``transform="logit"`` it must lie in ``(0, 1)`` (default ``0.5``, i.e. a zero log-odds shift); for ``transform="linear"`` any real value (``0.0`` recovers :func:`weighted_majority_vote` with ``aggregate="sum"``). transform: ``"logit"`` (log-odds; default) or ``"linear"``. return_index: If ``True``, also return the highest-scoring member of the winning answer group per question (``-1`` if none are valid). Note the winning group may have a negative total weight. return_score: If ``True``, also return the score of that representative candidate (``NaN`` if none are valid). Returns: The selected answer per question (scalar or ``(M,)`` object array). When ``return_index`` and/or ``return_score`` are set, a tuple ``(selected[, index][, score])`` is returned in that order. Formula: With per-candidate weight :math:`w_{\alpha i} = \operatorname{logit}(S_{\alpha i}) - \operatorname{logit}(b)` (or :math:`S_{\alpha i} - b` for the linear transform), .. math:: \hat z_\alpha = \operatorname*{arg\,max}_z \sum_{i:\, Z_{\alpha i} = z} w_{\alpha i}, with ties broken by earliest appearance. This is the paper's *practical parametric* instantiation: the per-response LLM-reliability offset of the full Theorem 3.2 weight is folded into the calibrated threshold ``b`` (the zero-crossing of the log-odds weight), so only ``b`` is exposed here. The nonparametric log-density-ratio weight is left to a caller who has a calibration set. Examples: >>> answers = ["A", "A", "B"] >>> scores = [0.4, 0.4, 0.9] # both A-candidates below b=0.5 >>> logit_weighted_vote(answers, scores) # A votes negative -> B wins 'B' >>> from scorio.aggregate import weighted_majority_vote >>> a, s = ["A", "A", "B"], [0.4, 0.4, 0.9] >>> logit_weighted_vote(a, s, transform="linear", threshold=0.0) == \ ... weighted_majority_vote(a, s) # linear, b=0 -> weighted sum True """ if transform not in ("logit", "linear"): raise ValueError(f"transform must be 'logit' or 'linear'; got {transform!r}.") Z, S, single = _normalize(answers, scores, require_scores=True) assert S is not None if transform == "logit": if not (0.0 < threshold < 1.0): raise ValueError( f"threshold must be in (0, 1) for transform='logit'; got {threshold}." ) # Validate scores lie strictly in (0, 1) for the log-odds transform. for row, srow in zip(Z, S, strict=True): for j in _valid_indices(row): s = float(srow[j]) if not (0.0 < s < 1.0): raise ValueError( "transform='logit' requires every valid score in (0, 1); " f"got {s}. Use transform='linear' for unbounded scores." ) return _run_vote( lambda row, srow: _row_logit(row, srow, threshold, transform), Z, S, single, return_index=return_index, return_score=return_score, )
[docs] def filtered_vote( answers: Any, scores: Any, *, keep: float = 0.5, weighted: bool = True, return_index: bool = False, return_score: bool = False, ) -> Any: r""" Confidence-filtered majority vote (DeepConf / top-K verifier-ranked vote). Drops the lowest-scoring candidates *before* voting, keeping only the top-``keep`` by score, then takes a (weighted) majority vote over the survivors. Filtering out low-quality candidates concentrates the vote on the model's most confident responses; the retained set then votes with weight equal to the score (``weighted=True``, DeepConf's confidence-weighted vote) or with unit weights (``weighted=False``, the classic top-K verifier-ranked majority vote of Cobbe et al.). References: Fu, Y., Wang, X., Tian, Y., & Zhao, J. (2025). Deep Think with Confidence. *arXiv:2508.15260*. https://arxiv.org/abs/2508.15260 Cobbe, K., Kosaraju, V., Bavarian, M., Chen, M., Jun, H., Kaiser, L., Plappert, M., Tworek, J., Hilton, J., Nakano, R., Hesse, C., & Schulman, J. (2021). Training Verifiers to Solve Math Word Problems (Sec. 5.1, top-K verifier voting). *arXiv:2110.14168*. Args: answers: ``(N,)`` or ``(M, N)`` array-like of answer labels. Unparsable entries (``None``, ``""``, ``NaN``) are ignored. scores: ``(N,)`` or ``(M, N)`` array-like of per-candidate scores (higher is better), same shape as ``answers``. keep: How many top-scoring candidates to retain per question. A float in ``(0, 1]`` is a *fraction* of the valid candidates (default ``0.5``, at least one is always kept); an integer ``>= 1`` is an explicit *count*. ``keep=1.0`` keeps everyone (no filtering); ``keep=1`` (integer) keeps only the single best, recovering Best-of-N. weighted: If ``True`` (default), survivors vote with weight equal to their score (DeepConf); if ``False``, with unit weights (top-K majority vote). return_index: If ``True``, also return the highest-scoring member of the winning answer group per question (``-1`` if none are valid). return_score: If ``True``, also return the score of that representative candidate (``NaN`` if none are valid). Returns: The selected answer per question (scalar or ``(M,)`` object array). When ``return_index`` and/or ``return_score`` are set, a tuple ``(selected[, index][, score])`` is returned in that order. Formula: Let :math:`\mathcal T_\alpha` be the ``k`` highest-scoring valid candidates for question :math:`\alpha` (``k`` from ``keep``; score ties broken by lowest index). Then .. math:: \hat z_\alpha = \operatorname*{arg\,max}_z \sum_{i \in \mathcal T_\alpha:\, Z_{\alpha i} = z} w_{\alpha i}, \qquad w_{\alpha i} = \begin{cases} S_{\alpha i} & \text{weighted} \\ 1 & \text{unweighted,} \end{cases} with ties broken by earliest appearance. Examples: >>> answers = ["A", "A", "A", "B", "B", "B"] >>> scores = [0.1, 0.2, 0.3, 0.8, 0.85, 0.9] >>> filtered_vote(answers, scores, keep=0.5) # top 3 are all B 'B' >>> filtered_vote(answers, scores, keep=1.0, weighted=False) # keep all -> majority tie, first 'A' """ Z, S, single = _normalize(answers, scores, require_scores=True) assert S is not None # Validate `keep` once up front with a representative row length. _keep_count(keep, Z.shape[1]) return _run_vote( lambda row, srow: _row_filtered(row, srow, keep, weighted), Z, S, single, return_index=return_index, return_score=return_score, )