Conversation
|
The design discussion lists the core behavior tests as TODO (identity, both modes, tied rankings, over-length returns, weight preservation, and regrouping). Before this API is finalized, could the PR add at least the deterministic identity/swap/error/weight cases? Those tests would also pin down the currently open None-return and regrouping semantics. |
Good call! I will be sure to add tests around these cases. We are still in the midst of discussing the design for the API, and I'm using this draft PR as notes and to-dos to keep track of where we end up. I'm implementing a new design this week and would love your feedback once it's up! |
Closes #340
Opening to discuss API and architecture decisions before polishing. See "Design discussion" below.
Summary
Adds a module-level utility that applies a user-defined function to every ballot in a
RankProfile.The user's function receives either a full
RankBallotor a plainlist[Candidate](the ranking, unwrapped), controlled by an explicitonparameter, and returns the same to produce the transformed profile.API
on="ballot"(default): the function receives aRankBallot. Fully general — handles ties, weights, and anything else on the ballot.on="candidates": the function receives the ranking as a flatlist[Candidate]— the friendliest form for common transforms like swapping two candidates. Requires untied rankings; raisesValueError(identifying the offending ballot) if the profile contains a tie.group_ballots=True: condenses identical ballot types before applying the function, so the function is called once per unique ballot type rather than once per ballot. On real CVR data, unique types are typically a small fraction of total ballots, so this is the main performance lever. Must be set toFalsefor stochastic/sampled transforms — see "Performance and correctness notes."Design choices worth flagging for review:
PreferenceProfile's surface focused. The issue sketchespref_profile.transform(...)— happy to add a thin wrapper method on the class if that's preferred; all logic would stay in the module either way.in_placeparameter. The issue sketchesin_place=Falseas the default; this draft omits the parameter entirely. True in-place mutation requires invalidating cached derived state on the profile and complicates every future mutating path. Can be added later if there's demand.CleanedProfile, the returned profile does not record its parent or which ballots changed. Transform and clean are related, so worth deciding whether they should converge.Behavior details
candidatesmode and over-length returned rankings raise immediately with the ballot index and offending content in the message, before further work is done.candidatesmode): returned candidates are re-wrapped into singleton frozensets and padded tomax_ranking_length.None/empty returns. Current draft treats them as an empty ranking; an alternative is drop the ballot.Performance and correctness notes
Making an arbitrary user-defined transform fast is fundamentally limited: the function's logic is opaque to the library, so it must be called from a Python loop — no vectorization is possible. The design accepts this and tries to pull remaining levers:
group_ballots=True): the only way to make an opaque function cheaper is to call it fewer times. Grouping first means cost scales with unique ballot types, not total ballots.candidatesmode skipsRankBallotconstruction and hands the function a plain list, iterating over the underlying array rather than pandas rows.Stochastic transforms and grouping do not mix. With
group_ballots=True, a probabilistic function (e.g. "swap A and B with probability 0.3") makes one draw per ballot type and applies it to the entire aggregated weight — 100 identical ballots all flip or none do, which is a different (wrong) distribution, not just a slower/faster trade-off. Sampled transforms must usegroup_ballots=Falseon a profile with unit weights. This is documented in the docstring; adeterministicflag that makes the contract explicit is an option if reviewers prefer.Testing (to-do)
candidatesmode raises with the ballot indexNone-return behavior, regroup behavior)Future work: predefined (non-opaque) transform functions
This section is the design discussion the draft is really for.
The general callable above is the escape hatch — it can express anything, at Python-loop speed. But most real transforms are drawn from a small vocabulary: swap two candidates, swap two candidates within some rank distance, truncate to top-k, remove a candidate, replace one ballot type with another. For these, we know the intent, which changes what's possible:
1. Wrapper functions (near-term, cheap). Predefined transforms shipped as ordinary functions that call
transform_ballots_profileinternally:Can utilize the internal df representation to perform operations with integers and constrain transform to ballots with matching operation.