Skip to content

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

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

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

Conversation

@roeiyanku

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

  • 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, elect for running a real process where no ground truth
    exists, and the evaluation metrics of §5.
  • 43 tests in tests/test_recommendation.py, plus doctests throughout.

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.

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.

2 participants