Skip to content

Add pabutools.recommendation: a recommendation system for participatory budgeting - #72

Open
roeiyanku wants to merge 17 commits into
COMSOC-Community:mainfrom
roeiyanku:pb-recommendation-system
Open

Add pabutools.recommendation: a recommendation system for participatory budgeting#72
roeiyanku wants to merge 17 commits into
COMSOC-Community:mainfrom
roeiyanku:pb-recommendation-system

Conversation

@roeiyanku

@roeiyanku roeiyanku commented Jul 27, 2026

Copy link
Copy Markdown

Implements "A Recommendation System for Participatory Budgeting" by Gil
Leibiker and Nimrod Talmon (AAMAS 2023):
https://optlearnmas23.github.io/files/p17.pdf

The module addresses the paper's information overload problem — the cognitive
burden of judging every project on a long ballot. Instead of asking every voter
about every project, it queries each voter for a partial ballot, estimates the
votes it did not ask for, and runs the voting rule on the completion.

Contents

pabutools.recommendation runs the system:

  • The five sampling setups of §3.1: random, offline by popularity, consensus
    and controversiality, and the online adaptive setup. next_adaptive_question
    exposes a single step of the adaptive loop so it can drive a live process,
    not only a simulation.
  • The three prediction modules of §2.1: binary classification with XGBoost,
    matrix factorization, and factorization machines.
  • The LV/TV partiality arithmetic of §3.0.1 (plan_sampling, split_lv_tv),
    greedy approval, and elect for running a real process where no ground
    truth exists.

pabutools.analysis.recommendationanalytics measures how well it did:

  • The accuracy metrics of §5: classification_metrics (precision, recall and
    F1 over the projects a voter was never asked about) and
    fractional_allocation_score (Definition 5.1).
  • run_all_experiments, the treatment matrix of §6 — every setup x predictor
    x sample degree x LV degree, averaged over repeated random splits.

Tests: 40 in tests/test_recommendation.py and 3 in tests/test_analysis.py,
plus 49 doctests collected by tests/test_doctests.py.

Dependencies

The machine-learning libraries are an optional recommendation extra and are
imported lazily inside the functions that need them, so the module imports and
every non-ML algorithm works without them. Tests that fit a model skip rather
than fail when the library is absent, which keeps CI green on the standard
.[dev] install.

Notes on the paper

Two points are under-specified or inconsistent; both are documented in the
docstrings alongside the reading that was implemented:

  • §2.1.1 states that binary classification is used and that XGBoost suits it,
    but never defines what a training row is or which features it carries.
  • §5.2.1 defines symmetric distance as |rb △ pb|, yet its second example gives
    SD({1,2,3}, {1,3,4}) = 1 where the definition yields 2. The first and third
    examples agree with the definition, which is what is implemented.

roeiyanku and others added 15 commits June 20, 2026 18:14
Implements the function headers (with doctests and empty bodies) and the
pytest test suite for the algorithms in "A Recommendation System for
Participatory Budgeting" (Leibiker & Talmon, 2023), integrating with the
pabutools data structures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Represent three-state partial ballots with pabutools' CardinalBallot
  (+1 approve / -1 disapprove / 0 hidden) instead of a new ballot type
- Split model training into a separate, ballot-agnostic module
  (pb_model_training.py); ML libs are an optional "recommendation" extra
  imported lazily
- Reuse pabutools where the primitive exists; keep empty stubs for this stage
- Samplers return the exposed set E_v as set[Project]; random_setup drops the
  unused ballots argument; remove symmetric_distance (per instructor)
- Use the paper's vocabulary throughout (Learning/Target Voters, full/partial
  ballot, ideal instance, A_v/D_v/E_v/H_v) and update the unit tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The predict_by_classification/matrix_factorization/factorization_machines
bodies still called the train_* functions; that is real code, not an empty
implementation. Remove those calls (and the now-unused pb_model_training
import) so every function body is an empty stub and all tests fail, as the
assignment requires. The predict/train relationship stays documented in the
docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the APPROVAL/DISAPPROVAL/HIDDEN module constants with a comment
documenting the +1/-1/0 convention, so the file contains only headers, empty
implementations, and documentation - no concrete code. Update the tests to
use the literal scores instead of importing the constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the lazy import guards (try/except ImportError for xgboost/surprise/
lightfm) from the train_* bodies - that was real code. Each body is now just
`return None  # Empty implementation`. The optional-dependency behaviour stays
documented in the docstrings for the later implementation stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Partial-ballot layer: build/decode the +1/-1/0 CardinalBallot convention
- Definitions 2.1/2.2 (approval scores via pabutools, consensus = |2*score-n|)
- Greedy approval by raw score with lexicographic tie-breaking (Section 2.2.5)
- Sampling modules: random, offline popularity/consensus/controversiality
  (top/bottom-k of the sigma/gamma orderings), online adaptive-controversial
- Prediction: LV-majority, XGBoost per-project classification, and
  matrix-factorization / factorization-machines via a shared NumPy SVD core
  (scikit-surprise and lightfm wheels do not build on this Windows + NumPy 2
  environment; the substitution is documented in the docstrings)
- Full pipeline (sampling -> prediction -> greedy) and the FA score (Def 5.1)
- INFO/DEBUG logging narrating every algorithm; helper functions with doctests

All 39 pytest tests and 102 doctests pass. Validated end-to-end on the paper's
Bielany 2020 Pabulib dataset, reproducing the paper's qualitative findings.
- greedy_approval now delegates to greedy_utilitarian_welfare with Cost_Sat:
  marginal satisfaction under Cost_Sat is score(p)*cost(p), so dividing by the
  cost ranks by the raw approval score - exactly the paper's greedy approval
  (verified equivalent, incl. tie-breaking, on 30 random instances)
- Delete approval_scores: Definition 2.1 is exactly the library's
  profile.approval_scores(); callers use it directly
- Delete most_popular_projects: the sigma ordering is inlined into its only
  remaining caller, offline_popularity
- Drop the TestApprovalScores class (the library tests its own method)

35 tests + doctests pass.
…tation

- partial_ballot: one dict-union expression instead of three loops
- reveal_ballot: single partial_ballot call on E&full, E-full, P-E
- predict_by_majority and _approve_hidden_by_score: the completion is now
  literally  A_v | {p in H_v : condition(p)}  as in the paper
- predict_by_classification: per-project votes as a dict comprehension,
  numpy import hoisted to the module level
- online_adaptive_controversial: min over set(instance)-exposed directly
- recommend: the sampling->prediction step is one list comprehension

Per-item DEBUG logs are folded into the per-function INFO summaries.
35 tests + doctests pass.
…trix

Full-implementation stage of "A Recommendation System for Participatory
Budgeting" (Leibiker & Talmon 2023), experiment side (Sections 3, 5, 6):

- split_lv_tv: Step 1, partition the ideal profile into Learning/Target
  Voters by the paper's sample-degree and LV-degree knobs (Section 3.0.1).
- run_pipeline (renamed from recommend): one cell of the design matrix;
  setup and predict are now required keyword arguments.
- exposed_sets: one interface over the five Section 3.1 setups.
- run_all_experiments: the full Section 6 treatment matrix (setups x
  predictors x sample degrees x LV degrees, n_repeat random splits
  averaged), reporting FA and inline Symmetric Distance per cell.
- classification_metrics: Section 5.1 precision/recall/F1 over the
  hidden projects, dependency-free.
- Remove predict_by_majority (not one of the paper's predictors) and its
  tests; PREDICTORS is now exactly classification/MF/FM.
- fractional_allocation_score accepts any Iterable[Project], so the
  BudgetAllocation from greedy_approval can be passed as is.
- Fix the [recommendation] extra: add scikit-learn (XGBClassifier needs
  xgboost's sklearn API), drop unused scikit-surprise/lightfm whose
  wheels do not build on Windows + NumPy 2.
- Add the paper link to every file header per the submission rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A structured two-camp electorate (60% majority approving {p1,p2,p3},
40% minority approving {p4,p5,p6}) fixes the real winning bundle; a
third of the voters become Target Voters with only k=2 of 6 projects
exposed. Every combination of the 5 sampling setups and 3 prediction
modules must reconstruct the exact real bundle (FA = 1.0, SD = 0) -
the paper's own success criterion (Section 2.4 / 5.2). 15 new cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Running "python pb_recommendation.py" now shows, like the course
examples: the doctest summary, the full DEBUG/INFO log trail of one
end-to-end pipeline run on a two-camp electorate (split -> sampling ->
per-voter prediction -> greedy approval -> FA score), and a validation
failure demo. exposed_sets validates its inputs: an unknown setup name
or a k outside [0, m] raises a ValueError naming the problem (was a
raw KeyError before). Covered by doctests and two pytest cases; also
fix the stale "recommend:" log prefix left from the run_pipeline rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The module's __main__ goes back to running only the doctests. The
ValueError validation in exposed_sets (unknown setup / k out of range)
stays, with its doctests and pytest coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run_pipeline now logs when it starts (setup, predictor, k, LV/TV
counts) and when all TV ballots are completed, so the log trail reads
chronologically. run_all_experiments announces the sweep dimensions
and the ground-truth bundle up front, logs every repeat's FA/SD at
DEBUG, and labels the per-cell INFO line as a mean over the repeats.
The iterable parameters are materialised as tuples first, so passing
generators no longer exhausts them before the sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Updated test cases by removing example1 fixture and several tests related to greedy approval and other functionalities. Adjusted edge case descriptions and cleaned up unused code.
…ry budgeting

Implements "A Recommendation System for Participatory Budgeting" by Gil
Leibiker and Nimrod Talmon (AAMAS 2023),
https://optlearnmas23.github.io/files/p17.pdf

The module tackles the paper's information overload problem: instead of asking
every voter about every project, it queries each voter for a partial ballot,
estimates the votes it did not ask for, and runs the voting rule on the
completion.

Contents:
* the five sampling setups of Section 3.1, plus next_adaptive_question so the
  online adaptive setup can drive a live process rather than only a simulation
* the three prediction modules of Section 2.1: binary classification with
  XGBoost, matrix factorization, and factorization machines
* the LV/TV partiality arithmetic of Section 3.0.1, greedy approval, elect for
  running a real process, and the evaluation metrics of Section 5
* 43 tests in tests/test_recommendation.py, plus doctests throughout

The machine-learning libraries are an optional "recommendation" extra and are
imported lazily, so the module imports and every non-ML algorithm works
without them; tests that need to fit a model skip rather than fail.

Two points where the paper is under-specified or inconsistent are documented
in the docstrings with the reading implemented: Section 2.1.1 never defines
what a training row is, and the Section 5.2.1 example disagrees with its own
definition of symmetric distance.
@Simon-Rey

Copy link
Copy Markdown
Member

Hey :) Thank you for your contribution. I don't fully understand what the code offers, could you explain to me how a user would use these functions and what they would provide?

Every other module in the library has a narrative usage page alongside its API
reference; this one only had the reference. The page walks through the two ways
the module is used - running a process from partial ballots with elect, and
measuring the loss on a known profile with run_experiment - with runnable
examples and cross-references into the reference pages.

Building the docs to check it also surfaced two markup errors in the module's
own docstrings, fixed here:

* |LV| and |TV| are substitution syntax in reStructuredText, so the cardinality
  notation in plan_sampling and split_lv_tv raised "Undefined substitution
  referenced" and never rendered; they are literals now.
* the cross-reference to greedy_utilitarian_welfare pointed at the re-export
  path rather than pabutools.rules.greedywelfare, where Sphinx documents it.
@roeiyanku

roeiyanku commented Jul 28, 2026

Copy link
Copy Markdown
Author

Thank you for the quick response! I'll explain below and also add an rst file.

In short: it lets a PB election run without asking every voter about every project. You ask each person about a handful, estimate the votes you never asked for, and the usual voting rule takes it from there. (For context, this came out of #58, which Erel opened for our Research Algorithms course.)

There are two flows.

Running an actual PB process where you cannot ask everyone about everything. You hold the ballots of the people who did fill one in completely, you choose which projects to put to everyone else, collect their partial answers, and get an outcome:

asked = offline_controversiality(instance, lv_profile, k=2)

{Library, Shade} <- the two projects the complete ballots most disagree about

answers = {
"v5": partial_ballot(approved={garden}, disapproved={crossings}),
"v6": partial_ballot(approved={crossings}, disapproved={garden}),
}

elect(instance, lv_profile, answers, predictor="classification")

BudgetAllocation([Garden, Shade])

So a user provides the instance, the ballots collected in full, and one partial ballot per remaining voter. They get back a BudgetAllocation, the same as from any other rule. The prediction only fills in projects a voter was never asked about; anything she actually answered is kept as given.

Evaluating the idea instead.
Here a user provides a complete profile and two numbers — how much of the vote to collect, and how much of that should come from complete ballots — and gets back the allocation that partial information would have produced, to compare against the real one:

real = set(greedy_approval(instance, profile))
predicted = set(run_experiment(instance, profile, 0.5, 0.5,
setup="offline_controversiality", seed=17))

fractional_allocation_score(real, predicted, instance.budget_limit) # 0.75

run_experiment is a simulation:
it splits the profile you already hold into the voters who answer in full and the ones who do not, reads each of the latter's answers to her k asked projects off her real ballot, and withholds the rest for the predictor to estimate. That is why it needs a complete profile, and why elect is the one to use when you actually collected the answers yourself. run_all_experiments repeats it across a whole grid of those two numbers, for every sampling setup and prediction module at once.

Everything else is the individual steps of those two flows, for anyone who wants to assemble them differently.

I pushed a usage page, docs-source/source/usage/recommendation.rst. It covers the above with runnable examples. Building the docs to check it also turned up two markup errors in my own docstrings (|LV| is substitution syntax in reStructuredText, and one :py:func: target was wrong); both are fixed in the same commit.

Happy to restructure any of it to fit your conventions.

The module mixed running the system with measuring it. The Section 5 metrics
and the Section 6 treatment matrix are analysis of an outcome, which is what
pabutools.analysis is for, so they move to a new
pabutools/analysis/recommendationanalytics.py:

* classification_metrics (Section 5.1) and fractional_allocation_score
  (Definition 5.1) are pure functions over sets of projects - the latter
  compares any two bundles and is not recommendation-specific at all
* run_all_experiments, SAMPLE_DEGREES and LV_DEGREES: a benchmark harness
  whose entire output is those metrics, with no role in running a process

The line is now that pabutools.recommendation runs the system and
pabutools.analysis measures how well it did. run_pipeline and run_experiment
stay behind: they are simulations, but they return bundles rather than scores,
and moving them would drag complete_ballots along with them.

Imports run analysis -> recommendation, the same direction mesanalytics
already uses to reach pabutools.rules; nothing in recommendation imports
analysis, so there is no cycle. The optional ML libraries stay lazily imported
inside the functions that need them, so pulling recommendation into
pabutools.analysis's __init__ does not make them hard dependencies.

Also replace the two "if __name__ == '__main__': doctest.testmod()" blocks
with tests/test_doctests.py. They were the only thing running the modules'
doctests - there is no pytest configuration in the project - so deleting them
outright would have quietly dropped 49 tests. The new module collects them as
an ordinary parameterized TestCase rather than through a load_tests hook,
because pytest does not implement that protocol and would collect none of
them; the examples that fit a model skip when their optional library is
absent, as the equivalent unit tests already do.

Full suite: 280 passed, 9 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roeiyanku

Copy link
Copy Markdown
Author

Following review feedback, I moved the evaluation code to a more appropriate place in the library.

pabutools.recommendation was mixing two different things: running the system, and measuring how well it ran. The Section 5 accuracy metrics and the Section 6 experiment sweep are analysis of an outcome, which is what pabutools.analysis exists for — fractional_allocation_score in particular just compares two bundles and isn't recommendation-specific at all. They now live in pabutools/analysis/recommendationanalytics.py:

  • classification_metrics, fractional_allocation_score
  • run_all_experiments, SAMPLE_DEGREES, LV_DEGREES

run_pipeline and run_experiment stayed put — they're simulations, but they return bundles rather than scores, and moving them would have dragged complete_ballots along too.

Imports run analysis -> recommendation, the same direction mesanalytics already uses to reach pabutools.rules, and nothing in recommendation imports analysis, so there is no cycle. The ML libraries are still imported lazily inside the functions that need them, so pulling recommendation into pabutools.analysis's __init__ does not make them hard dependencies.

One related cleanup: the two if __name__ == "__main__": doctest.testmod() blocks are gone, replaced by tests/test_doctests.py. Since the project has no pytest configuration, those blocks were the only thing running the modules' doctests, so removing them without a replacement would have silently dropped 49 tests.

Full suite: 280 passed, 9 skipped.

@roeiyanku

Copy link
Copy Markdown
Author

Hey!Just wanted to follow up on the PR I submitted a couple of weeks ago. Whenever you get a chance, I’d really appreciate a review. Let me know if there’s anything you’d like me to change or add. Thanks!

@DominikPeters

DominikPeters commented Aug 9, 2026

Copy link
Copy Markdown
Member

Hi! It doesn't feel to me like this material belongs to a library like pabutools, since it doesn't seem to be generally useful. Edit: I see that Simon in the issue discussion seems to have indicated this would be welcome, it's his call.

Generally, I think it isn't good form to let Claude write any text like PR descriptions or comments that you expect other people to read. It's better to write yourself to explain what you're doing and why you think it is a good addition.

@roeiyanku

Copy link
Copy Markdown
Author

Thank you for the constructive criticism. If @Simon-Rey thinks there's much to cut down that's not a problem, I'll edit as necessary. In general the pr is meant to give the user the tools, based on machine learning algorithms, to predict the results of the full ballots, as most voters in real life will leave many of the projects unanswered.

@Simon-Rey

Copy link
Copy Markdown
Member

I think my initial concern (and from what I understand also that of Dominik) is that I don't really think that users of the package will use that piece of code. The functionalities offered by a package need to be general, solve problem that several people will want to look into. Right now it all feels too specific for me to really believe that others will get advantage from it.

There are thousands of lines of code that all seem to me to have been AI generated (no problem with that, but it does not give me great confidence) and so I want to be a bit careful before introducing that to the package as they will need to be maintained at some point.

Overall, I don't know what to do with all of this and since Dominik also raised concerns I am tempted to close without merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants