diff --git a/.gitignore b/.gitignore index f0eee36a..bbd0143a 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,4 @@ analysis/Pabulib .vscode/ .python-version -.DS_Store +.DS_Store \ No newline at end of file diff --git a/pabutools/analysis/cohesiveness.py b/pabutools/analysis/cohesiveness.py index 0724ff0e..814312e5 100644 --- a/pabutools/analysis/cohesiveness.py +++ b/pabutools/analysis/cohesiveness.py @@ -1,5 +1,6 @@ from __future__ import annotations +import random from collections.abc import Collection from pabutools.utils import Numeric @@ -68,29 +69,75 @@ def is_cohesive_cardinal( return True -def cohesive_groups(instance: Instance, profile: AbstractProfile, projects=None): +def cohesive_groups( + instance: Instance, + profile: AbstractProfile, + projects=None, + sample_size: int | None = None, +): + """ + Find (group, project_set) pairs that are cohesive. + + Parameters + ---------- + instance : Instance + profile : AbstractProfile + projects : Collection[Project], optional + Defaults to all projects in `instance`. + sample_size : int, optional + If `None` (default), exhaustively check every (group, project_set) + pair via `powerset(profile) x powerset(projects)`. If set to an int, + instead randomly sample that many (group, project_set) pairs — use + this to keep runtime bounded on large instances, where the + exhaustive enumeration is exponential in both the number of voters + and the number of projects. + """ if projects is None: projects = instance + projects = list(projects) + ballots = list(profile) res = [] - for group in powerset(profile): - if len(group) > 0: - for project_set in powerset(projects): - if len(project_set) > 0: - # This fails for multiprofiles as the multiplicity is not taken into account - if isinstance(profile, AbstractApprovalProfile): - if is_cohesive_approval(instance, profile, project_set, group): - res.append((group, project_set)) - elif isinstance(profile, AbstractCardinalProfile): - alpha_min = {p: min(b[p] for b in group) for p in project_set} - if is_cohesive_cardinal( - instance, profile, project_set, group, alpha_min - ): - res.append((group, project_set)) - else: - raise NotImplementedError( - f"We cannot find cohesive groups in a profile of type {type(profile)}. " - f"Only approval and cardinal profiles are supported." - ) + + if sample_size is None: + group_project_pairs = ( + (group, project_set) + for group in powerset(profile) + for project_set in powerset(projects) + ) + else: + def _sampled_pairs(): + for _ in range(sample_size): + group = ( + random.sample(ballots, random.randint(1, len(ballots))) + if ballots + else [] + ) + project_set = ( + random.sample(projects, random.randint(1, len(projects))) + if projects + else [] + ) + yield group, project_set + + group_project_pairs = _sampled_pairs() + + for group, project_set in group_project_pairs: + if len(group) > 0 and len(project_set) > 0: + # This fails for multiprofiles as the multiplicity is not taken into account + if isinstance(profile, AbstractApprovalProfile): + if is_cohesive_approval(instance, profile, project_set, group): + res.append((group, project_set)) + elif isinstance(profile, AbstractCardinalProfile): + alpha_min = {p: min(b[p] for b in group) for p in project_set} + if is_cohesive_cardinal( + instance, profile, project_set, group, alpha_min + ): + res.append((group, project_set)) + else: + raise NotImplementedError( + f"We cannot find cohesive groups in a profile of type {type(profile)}. " + f"Only approval and cardinal profiles are supported." + ) return res diff --git a/pabutools/analysis/justifiedrepresentation.py b/pabutools/analysis/justifiedrepresentation.py index ebf9431c..40681e2d 100644 --- a/pabutools/analysis/justifiedrepresentation.py +++ b/pabutools/analysis/justifiedrepresentation.py @@ -1,7 +1,14 @@ from __future__ import annotations +import random from collections.abc import Collection, Callable, Iterable -import gurobipy as gp +from itertools import combinations +try: + import gurobipy as gp + _GUROBI_AVAILABLE = True +except ImportError: + gp = None + _GUROBI_AVAILABLE = False from pabutools.utils import Numeric @@ -252,12 +259,20 @@ def is_EJR_approval( sat_class: type[SatisfactionMeasure], budget_allocation: Collection[Project], up_to_func: Callable[[Iterable[Numeric]], Numeric] | None = None, + sample_size: int | None = None, ) -> bool: """ Test if a budget allocation satisfies EJR for the given instance and the given profile of approval ballots. + + Parameters + ---------- + sample_size : int, optional + If `None` (default), exhaustively check every cohesive group. If set + to an int, randomly sample that many groups instead — use this to + keep runtime bounded on large instances. """ - for group, project_set in cohesive_groups(instance, profile): + for group, project_set in cohesive_groups(instance, profile, sample_size=sample_size): one_agent_sat = False for ballot in group: sat = sat_class(instance, profile, ballot) @@ -320,12 +335,20 @@ def is_PJR_approval( sat_class: type[SatisfactionMeasure], budget_allocation: Collection[Project], up_to_func: Callable[[Iterable[Numeric]], Numeric] | None = None, + sample_size: int | None = None, ) -> bool: """ Test if a budget allocation satisfies PJR for the given instance and the given profile of approval ballots. + + Parameters + ---------- + sample_size : int, optional + If `None` (default), exhaustively check every cohesive group. If set + to an int, randomly sample that many groups instead — use this to + keep runtime bounded on large instances. """ - for group, project_set in cohesive_groups(instance, profile): + for group, project_set in cohesive_groups(instance, profile, sample_size=sample_size): sat = sat_class(instance, profile, ApprovalBallot(instance)) threshold = sat.sat(project_set) group_approved = {p for p in budget_allocation if any(p in b for b in group)} @@ -515,3 +538,174 @@ def is_PJR_one_cardinal( return is_PJR_cardinal( instance, profile, budget_allocation, up_to_func=lambda x: max(x, default=0) ) + + +def is_FJR_approval( + instance: Instance, + profile: AbstractApprovalProfile, + sat_class: type[SatisfactionMeasure], + budget_allocation: Collection[Project], + max_subset_size: int | None = None, + sample_size: int | None = None, +) -> bool: + """ + Test whether a budget allocation satisfies Full Justified Representation (FJR), + per Definition 5.3 of Aziz et al. (2024) "Fair Lotteries for Participatory + Budgeting" (the same weakly (beta,T)-cohesive group definition used by + :func:`~pabutools.rules.gcr.gcr_rule.greedy_cohesive_rule`). + + For each candidate project set T and threshold beta (1 <= beta <= |T|), the + group S of voters with positive satisfaction for at least beta projects in T + is formed. If S's fair share of the budget can afford T, FJR requires at + least one voter in S to have utility >= beta in the outcome. Returns False + as soon as a violation is found. + + Parameters + ---------- + instance : Instance + The PB instance (projects + budget limit). + profile : AbstractApprovalProfile + The voters' approval ballots. + sat_class : type[SatisfactionMeasure] + Class defining how voter satisfaction is measured. + budget_allocation : Collection[Project] + The allocation being tested. + max_subset_size : int, optional + Caps |T| in the search (None = unlimited, exponential but exhaustive). + sample_size : int, optional + If `None` (default), exhaustively check every (T, beta) pair via + `combinations`. If set to an int, randomly sample that many (T, beta) + pairs instead — use this to keep runtime bounded on large instances. + + Returns + ------- + bool + True if no FJR violation was found. + """ + projects = list(instance) + n = profile.num_ballots() + B = instance.budget_limit + sat_per_voter = [sat_class(instance, profile, ballot) for ballot in profile] + + max_size = max_subset_size if max_subset_size is not None else len(projects) + + if sample_size is None: + candidates = ( + (T, beta) + for r in range(1, max_size + 1) + for T in combinations(projects, r) + for beta in range(1, r + 1) + ) + else: + def _sampled_candidates(): + for _ in range(sample_size): + r = random.randint(1, max_size) + T = tuple(random.sample(projects, r)) + beta = random.randint(1, r) + yield T, beta + + candidates = _sampled_candidates() + + for T, beta in candidates: + cost_T = total_cost(T) + if cost_T <= 0 or cost_T > B: + continue + + S_idx = [ + i + for i, sat in enumerate(sat_per_voter) + if sum(1 for p in T if sat.sat_project(p) > 0) >= beta + ] + if not S_idx: + continue + if not is_large_enough(len(S_idx), n, cost_T, B): + continue + + exists_satisfied = any( + sat_per_voter[i].sat(budget_allocation) >= beta for i in S_idx + ) + if not exists_satisfied: + return False + return True + + +def is_strong_UFS_approval( + instance: Instance, + profile: AbstractApprovalProfile, + sat_class: type[SatisfactionMeasure], + fractional_allocation: dict[Project, Numeric], + sample_size: int | None = None, + tolerance: Numeric = 1e-7, +) -> bool: + """ + Test whether a fractional outcome satisfies strong Unanimous Fair Share (UFS). + + Considers unanimous groups S (all voters in S have identical approval + ballots) and compares the algorithm's fractional utility for a member of S + against the optimal fractional utility achievable with S's fair share of + the budget. Returns False as soon as a violation is found. + + Parameters + ---------- + instance : Instance + The PB instance (projects + budget limit). + profile : AbstractApprovalProfile + The voters' approval ballots. + sat_class : type[SatisfactionMeasure] + Class defining how voter satisfaction is measured. + fractional_allocation : dict[Project, Numeric] + The probability/share assigned to each project by the algorithm. + sample_size : int, optional + If `None` (default), exhaustively check every unanimous group. If set + to an int, randomly sample that many groups instead — use this to + keep runtime bounded on large instances. + tolerance : Numeric, optional + Numerical tolerance for the utility comparison. Defaults to 1e-7. + + Returns + ------- + bool + True if no strong UFS violation was found. + """ + ballots = list(profile) + n = profile.num_ballots() + B = instance.budget_limit + + if sample_size is None: + group_iter = (group for group in powerset(profile) if len(group) > 0) + else: + def _sampled_groups(): + for _ in range(sample_size): + yield random.sample(ballots, random.randint(1, len(ballots))) + + group_iter = _sampled_groups() + + for group in group_iter: + if len({frozenset(b) for b in group}) > 1: + continue # not unanimous + + sat = sat_class(instance, profile, group[0]) + B_S = (len(group) / n) * B + + util_alg = sum( + fractional_allocation.get(p, 0) + for p in instance + if sat.sat_project(p) > 0 + ) + + liked_projects = sorted( + (p for p in instance if sat.sat_project(p) > 0), key=lambda p: p.cost + ) + util_opt = 0.0 + remaining_B = B_S + for p in liked_projects: + if p.cost <= remaining_B: + util_opt += 1.0 + remaining_B -= p.cost + else: + util_opt += remaining_B / p.cost + break + + if not util_alg + tolerance >= util_opt: + return False + return True \ No newline at end of file diff --git a/pabutools/election/instance.py b/pabutools/election/instance.py index dd926526..c4219e9e 100644 --- a/pabutools/election/instance.py +++ b/pabutools/election/instance.py @@ -544,3 +544,24 @@ def get_random_instance(num_projects: int, min_cost: int, max_cost: int) -> Inst ceil(min(p.cost for p in inst)), ceil(sum(p.cost for p in inst)) ) return inst + + +def instance_from_project_costs(project_costs: dict, budget_limit: Numeric) -> Instance: + """ + Create an :class:`Instance` from a ``{project_name: cost}`` mapping and a budget limit. + + Parameters + ---------- + project_costs : dict + Maps each project name (str) to its cost (int or float). + budget_limit : Numeric + The total available budget. + + Returns + ------- + Instance + """ + return Instance( + [Project(name, cost) for name, cost in project_costs.items()], + budget_limit=budget_limit, + ) diff --git a/pabutools/election/profile/__init__.py b/pabutools/election/profile/__init__.py index 99369f19..3ec807a9 100644 --- a/pabutools/election/profile/__init__.py +++ b/pabutools/election/profile/__init__.py @@ -40,11 +40,13 @@ ApprovalMultiProfile, get_random_approval_profile, get_all_approval_profiles, + approval_profile_from_matrix, ) from pabutools.election.profile.cardinalprofile import ( AbstractCardinalProfile, CardinalProfile, CardinalMultiProfile, + cardinal_profile_from_matrix, ) from pabutools.election.profile.cumulativeprofile import ( AbstractCumulativeProfile, @@ -66,9 +68,11 @@ "ApprovalMultiProfile", "get_random_approval_profile", "get_all_approval_profiles", + "approval_profile_from_matrix", "AbstractCardinalProfile", "CardinalProfile", "CardinalMultiProfile", + "cardinal_profile_from_matrix", "AbstractCumulativeProfile", "CumulativeProfile", "CumulativeMultiProfile", diff --git a/pabutools/election/profile/approvalprofile.py b/pabutools/election/profile/approvalprofile.py index 74fea0e9..87973e4a 100644 --- a/pabutools/election/profile/approvalprofile.py +++ b/pabutools/election/profile/approvalprofile.py @@ -357,6 +357,42 @@ def get_all_approval_profiles( yield ApprovalProfile([ApprovalBallot(b) for b in p], instance=instance) +def approval_profile_from_matrix( + voters: list, + approvals: dict, + instance: Instance, +) -> ApprovalProfile: + """ + Create an :class:`ApprovalProfile` from a binary ``{0, 1}`` matrix. + + Thin wrapper around + :func:`~pabutools.election.profile.cardinalprofile.cardinal_profile_from_matrix` + with ``to_approval=True`` and ``threshold=0``. See that function for the + more general case of arbitrary numeric weights. + + Parameters + ---------- + voters : list + Ordered list of voter identifiers. + approvals : dict + Maps each voter identifier to a ``{project_name: 0_or_1}`` dict where + 1 means the voter approves that project and 0 means they do not. + instance : Instance + The pabutools :class:`~pabutools.election.instance.Instance` whose + :class:`~pabutools.election.instance.Project` objects will be referenced + in the ballots. + + Returns + ------- + ApprovalProfile + """ + from pabutools.election.profile.cardinalprofile import cardinal_profile_from_matrix + + return cardinal_profile_from_matrix( + voters, approvals, instance, to_approval=True, threshold=0 + ) + + class ApprovalMultiProfile(MultiProfile, AbstractApprovalProfile): """ A multiprofile of approval ballots, that is, a multiset of approval ballots together with their multiplicity. diff --git a/pabutools/election/profile/cardinalprofile.py b/pabutools/election/profile/cardinalprofile.py index a800a474..b9a67828 100644 --- a/pabutools/election/profile/cardinalprofile.py +++ b/pabutools/election/profile/cardinalprofile.py @@ -263,6 +263,69 @@ def inner(self, *args): ) +def cardinal_profile_from_matrix( + voters: list, + weights: dict, + instance: Instance, + to_approval: bool = False, + threshold: Numeric = 0, +): + """ + Create a :class:`CardinalProfile` from a weight matrix, or an + :class:`~pabutools.election.profile.approvalprofile.ApprovalProfile` + thresholded from those weights. + + Parameters + ---------- + voters : list + Ordered list of voter identifiers. + weights : dict + Maps each voter identifier to a ``{project_name: numeric_weight}`` + dict. Projects not mentioned for a voter default to weight 0. + instance : Instance + The pabutools :class:`~pabutools.election.instance.Instance` whose + :class:`~pabutools.election.instance.Project` objects will be + referenced in the ballots. + to_approval : bool, optional + If `True`, return an `ApprovalProfile` where a project is approved iff + its weight is strictly greater than `threshold`. If `False` + (default), return a `CardinalProfile` carrying the raw weights. + threshold : Numeric, optional + Cutoff used only when `to_approval` is `True`. Defaults to 0. + + Returns + ------- + CardinalProfile or ApprovalProfile + """ + project_by_name = {p.name: p for p in instance} + + if to_approval: + from pabutools.election.profile.approvalprofile import ( + ApprovalProfile, + ApprovalBallot, + ) + + ballots = [] + for voter in voters: + voter_weights = weights[voter] + ballot = ApprovalBallot( + p + for name, p in project_by_name.items() + if voter_weights.get(name, 0) > threshold + ) + ballots.append(ballot) + return ApprovalProfile(ballots, instance=instance) + + ballots = [] + for voter in voters: + voter_weights = weights[voter] + ballot = CardinalBallot( + {p: voter_weights.get(name, 0) for name, p in project_by_name.items()} + ) + ballots.append(ballot) + return CardinalProfile(ballots, instance=instance) + + class CardinalMultiProfile(MultiProfile, AbstractCardinalProfile): """ A multiprofile of cardinal ballots, that is, a multiset of cardinal ballots together with their multiplicity. diff --git a/pabutools/fractions.py b/pabutools/fractions.py index b2d0cfea..90819c0c 100644 --- a/pabutools/fractions.py +++ b/pabutools/fractions.py @@ -43,21 +43,39 @@ def frac(*arg: Numeric) -> Numeric: Numeric The fraction. """ + # Normalize numpy scalars (and similar) to plain Python ints/floats so that + # gmpy2.mpq and arithmetic operators accept them without errors. Done + # inline per-argument (no list/tuple allocation) since this function is + # called everywhere and needs to stay cheap. if len(arg) == 1: + a0 = arg[0] + try: + a0 = a0.item() + except AttributeError: + pass if FRACTION == GMPY_FRAC: - return mpq(arg[0]) + return mpq(a0) elif FRACTION == FLOAT_FRAC: - return arg[0] + return a0 else: raise ValueError( f"The current value of pabutools.fractions.FRACTION '{FRACTION}' is invalid, it needs to be in " "[gmpy2, float]." ) elif len(arg) == 2: + a0, a1 = arg + try: + a0 = a0.item() + except AttributeError: + pass + try: + a1 = a1.item() + except AttributeError: + pass if FRACTION == GMPY_FRAC: - return mpq(arg[0], arg[1]) + return mpq(a0, a1) elif FRACTION == FLOAT_FRAC: - return arg[0] / arg[1] + return a0 / a1 else: raise ValueError( f"The current value of pabutools.fractions.FRACTION '{FRACTION}' is invalid, it needs to be in " diff --git a/pabutools/rules/__init__.py b/pabutools/rules/__init__.py index e750a9cf..be11ab55 100644 --- a/pabutools/rules/__init__.py +++ b/pabutools/rules/__init__.py @@ -40,6 +40,18 @@ ) from pabutools.rules.cstv import cstv, CSTV_Combination from pabutools.rules.maximin_support import maximin_support +from pabutools.rules.gcr import greedy_cohesive_rule, GCRAllocationDetails, GCRIteration +from pabutools.rules.lottery import ( + dependent_rounding_bb1, + BW_GCR_PB, + BW_GCR_PB_from_lists, + BW_GCR_PB_wrapped, + BW_MES_PB, + BW_MES_PB_from_lists, + BW_MES_PB_wrapped, + build_instance, + build_profile, +) from pabutools.rules.ordered_relax import ordered_relax from pabutools.rules.ees_addopt import ( exact_equal_shares, @@ -65,6 +77,18 @@ "cstv", "CSTV_Combination", "maximin_support", + "greedy_cohesive_rule", + "GCRAllocationDetails", + "GCRIteration", + "dependent_rounding_bb1", + "BW_GCR_PB", + "BW_GCR_PB_from_lists", + "BW_GCR_PB_wrapped", + "BW_MES_PB", + "BW_MES_PB_from_lists", + "BW_MES_PB_wrapped", + "build_instance", + "build_profile", "exact_equal_shares", "greedy_project_change", "add_opt", diff --git a/pabutools/rules/gcr/__init__.py b/pabutools/rules/gcr/__init__.py new file mode 100644 index 00000000..771d75a8 --- /dev/null +++ b/pabutools/rules/gcr/__init__.py @@ -0,0 +1,4 @@ +from pabutools.rules.gcr.gcr_rule import greedy_cohesive_rule +from pabutools.rules.gcr.gcr_details import GCRAllocationDetails, GCRIteration + +__all__ = ["greedy_cohesive_rule", "GCRAllocationDetails", "GCRIteration"] diff --git a/pabutools/rules/gcr/gcr_details.py b/pabutools/rules/gcr/gcr_details.py new file mode 100644 index 00000000..26ec4567 --- /dev/null +++ b/pabutools/rules/gcr/gcr_details.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from pabutools.election.instance import Project +from pabutools.rules.budgetallocation import AllocationDetails + + +@dataclass +class GCRIteration: + """ + Stores information about a single iteration of the Greedy Cohesive Rule. + + Attributes + ---------- + beta : int + The β value of the cohesive group selected in this iteration. + selected_projects : list[Project] + The set T of projects added to W in this iteration. + deactivated_voters : list[int] + Indices of the voters in N' that were deactivated in this iteration. + active_voters_remaining : int + Number of still-active voters after this iteration. + """ + + beta: int + selected_projects: list[Project] + deactivated_voters: list[int] + active_voters_remaining: int + + +@dataclass +class GCRAllocationDetails(AllocationDetails): + """ + Stores the full execution trace of the Greedy Cohesive Rule. + + Attributes + ---------- + iterations : list[GCRIteration] + One entry per GCR iteration, in order of execution. + """ + + iterations: list[GCRIteration] = field(default_factory=list) diff --git a/pabutools/rules/gcr/gcr_rule.py b/pabutools/rules/gcr/gcr_rule.py new file mode 100644 index 00000000..563eaa14 --- /dev/null +++ b/pabutools/rules/gcr/gcr_rule.py @@ -0,0 +1,191 @@ +""" +Implementation of the Greedy Cohesive Rule (GCR) from: + Peters, Pierczyński, and Skowron (2021). + "Proportional Participatory Budgeting with Additive Utilities." + NeurIPS 2021. + +Used as the deterministic backbone of BW-GCR-PB in: + Aziz, Lu, Suzuki, Vollen, and Walsh (2024). + "Fair Lotteries for Participatory Budgeting." + AAAI 2024. + +Per Definition 5.3 of the AAAI 2024 paper, a group S is weakly (β,T)-cohesive if: + (1) |S| · B/n ≥ cost(T) [size: group's fair share covers T] + (2) |Aᵢ ∩ T| ≥ β for all i ∈ S [β = min approvals of T across S] + +GCR maximises β, breaks ties by smaller cost(T), then by larger |S|. + +Programmers: Dotan Danino, Naama Yahav. +Date: 1/6/2026 + +""" + +from __future__ import annotations + +from itertools import combinations + +from pabutools.election.instance import Instance, total_cost +from pabutools.election.profile import AbstractProfile, AbstractApprovalProfile +from pabutools.election.satisfaction import ( + SatisfactionMeasure, + GroupSatisfactionMeasure, + Cardinality_Sat, +) +from pabutools.rules.budgetallocation import BudgetAllocation +from pabutools.rules.gcr.gcr_details import GCRAllocationDetails, GCRIteration + + +def greedy_cohesive_rule( + instance: Instance, + profile: AbstractProfile, + sat_class: type[SatisfactionMeasure] | None = None, + sat_profile: GroupSatisfactionMeasure | None = None, + analytics: bool = False, + max_subset_size: int | None = None, +) -> BudgetAllocation: + """ + The Greedy Cohesive Rule (GCR) for participatory budgeting. + + Per Definition 5.3 of Aziz et al. (2024), a group S of active voters is + weakly (β,T)-cohesive for a project set T if: + + |S| · B/n ≥ cost(T) [size condition] + |{p ∈ T : satᵢ(p) > 0}| ≥ β [β = positive-satisfaction count] + + In each iteration GCR finds the (S, T) pair maximising β, breaking ties + by smaller cost(T) then larger |S|. T is added to W and S is deactivated. + Terminates when no weakly (β,T)-cohesive group exists for any β ≥ 1. + + Parameters + ---------- + instance : Instance + The PB instance (projects + budget limit). + profile : AbstractProfile + The voters' ballots (approval, cardinal, ordinal, or any other type + supported by the chosen satisfaction measure). + sat_class : type[SatisfactionMeasure], optional + Class defining how voter satisfaction is measured. Defaults to + :class:`~pabutools.election.satisfaction.Cardinality_Sat` when + *profile* is an approval profile. Must be provided for non-approval + profiles. + sat_profile : GroupSatisfactionMeasure, optional + A pre-computed satisfaction profile. If given, *sat_class* is ignored + for conversion (but the profile type check still applies). + analytics : bool, optional + If True, attaches a :class:`GCRAllocationDetails` object to the result. + max_subset_size : int or None, optional + Caps |T| in the search (None = unlimited, exponential but correct). + + Returns + ------- + BudgetAllocation + The projects selected by GCR. + + Examples + -------- + >>> from pabutools.election.instance import Instance, Project + >>> from pabutools.election.profile import ApprovalProfile + >>> from pabutools.election.ballot import ApprovalBallot + >>> p1 = Project("p1", cost=30) + >>> p2 = Project("p2", cost=30) + >>> p3 = Project("p3", cost=30) + >>> instance = Instance([p1, p2, p3], budget_limit=60) + >>> profile = ApprovalProfile([ + ... ApprovalBallot([p1, p2]), + ... ApprovalBallot([p1, p2]), + ... ApprovalBallot([p3]), + ... ]) + >>> result = greedy_cohesive_rule(instance, profile) + >>> sorted(p.name for p in result) + ['p1'] + """ + # Resolve satisfaction: default to Cardinality_Sat for approval profiles. + if sat_class is None and sat_profile is None: + if isinstance(profile, AbstractApprovalProfile): + sat_class = Cardinality_Sat + else: + raise ValueError( + "sat_class and sat_profile cannot both be None for non-approval profiles. " + "Please provide a sat_class (e.g. Additive_Cardinal_Sat for cardinal profiles)." + ) + + if sat_profile is None: + sat_profile = profile.as_sat_profile(sat_class) + + sat_measures = list(sat_profile) # one SatisfactionMeasure per voter + + n = profile.num_ballots() + B = instance.budget_limit + + W: set = set() + selected: list = [] + active: list[int] = list(range(n)) + details = GCRAllocationDetails() if analytics else None + + while active: + unselected = [p for p in instance if p not in W] + if not unselected: + break + + max_size = max_subset_size if max_subset_size is not None else len(unselected) + + best_score: tuple | None = None # (beta, -cost_T, len_N_prime) + best_T: tuple | None = None + best_N_prime: list | None = None + + for r in range(1, max_size + 1): + for T in combinations(unselected, r): + cost_T = total_cost(T) + if cost_T <= 0 or cost_T > B: + continue + + # For each active voter, count projects in T with positive satisfaction. + # This generalises "p in approval_ballot" to any satisfaction measure. + approval_counts = [ + sum(1 for p in T if sat_measures[i].sat_project(p) > 0) + for i in active + ] + + for k in range(r, 0, -1): + # S = active voters who have positive satisfaction for ≥ k projects in T + N_prime = [ + active[idx] + for idx, cnt in enumerate(approval_counts) + if cnt >= k + ] + if not N_prime: + continue + # Size condition: |S| · B/n ≥ cost(T) + if len(N_prime) * B < n * cost_T: + continue + # Valid: β = k for this (T, S) pair + score = (k, -float(cost_T), len(N_prime)) + if best_score is None or score > best_score: + best_score = score + best_T = T + best_N_prime = N_prime + break # k is the best achievable for this T; lower k can't improve + + if best_T is None: + break # no weakly cohesive group remains; terminate + + selected.extend(best_T) + W.update(best_T) + + deactivated = set(best_N_prime) + active = [i for i in active if i not in deactivated] + + if analytics: + details.iterations.append( + GCRIteration( + beta=best_score[0], + selected_projects=list(best_T), + deactivated_voters=list(deactivated), + active_voters_remaining=len(active), + ) + ) + + allocation = BudgetAllocation(selected) + if analytics: + allocation.details = details + return allocation diff --git a/pabutools/rules/lottery/__init__.py b/pabutools/rules/lottery/__init__.py new file mode 100644 index 00000000..aa1196a4 --- /dev/null +++ b/pabutools/rules/lottery/__init__.py @@ -0,0 +1,48 @@ +""" +Ballot-weighted lottery rules for participatory budgeting. + +Implements the algorithms from: + Aziz, Lu, Suzuki, Vollen, and Walsh (2024). + "Fair Lotteries for Participatory Budgeting." AAAI 2024. + +The two main entry points are: + +* :func:`~pabutools.rules.lottery.BW_GCR_PB_wrapped` — GCR-backed lottery (FJR) +* :func:`~pabutools.rules.lottery.BW_MES_PB_wrapped` — MES-backed lottery (EJR) +""" + +from pabutools.election.instance import instance_from_project_costs +from pabutools.election.profile import approval_profile_from_matrix +from pabutools.rules.lottery.lottery_rule import ( + # BB1 rounding + dependent_rounding_bb1, + # Algorithm 1 (GCR) + BW_GCR_PB, + BW_GCR_PB_from_lists, + BW_GCR_PB_wrapped, + # Algorithm 2 (MES) + BW_MES_PB, + BW_MES_PB_from_lists, + BW_MES_PB_wrapped, + # Backward-compatible aliases + build_instance, + build_profile, + approval_sat, +) + +__all__ = [ + "dependent_rounding_bb1", + "BW_GCR_PB", + "BW_GCR_PB_from_lists", + "BW_GCR_PB_wrapped", + "BW_MES_PB", + "BW_MES_PB_from_lists", + "BW_MES_PB_wrapped", + # Preferred names + "instance_from_project_costs", + "approval_profile_from_matrix", + # Backward-compatible aliases + "build_instance", + "build_profile", + "approval_sat", +] diff --git a/pabutools/rules/lottery/lottery_rule.py b/pabutools/rules/lottery/lottery_rule.py new file mode 100644 index 00000000..78fc95e6 --- /dev/null +++ b/pabutools/rules/lottery/lottery_rule.py @@ -0,0 +1,659 @@ +""" +Implementation of the ballot-weighted lottery algorithms from: + Aziz, Lu, Suzuki, Vollen, and Walsh (2024). + "Fair Lotteries for Participatory Budgeting." + AAAI 2024. + +Two algorithms are provided: + +* BW-GCR-PB (Algorithm 1): uses GCR as deterministic backbone, satisfies FJR. +* BW-MES-PB (Algorithm 2): uses MES as deterministic backbone, satisfies EJR. + +Both algorithms return a fractional probability vector and apply BB1 dependent +rounding to produce a discrete lottery outcome. + +Programmers: Dotan Danino, Naama Yahav. +""" + +from __future__ import annotations + +import logging +import random + +from pabutools.election.ballot import ApprovalBallot +from pabutools.election.instance import Instance, Project, instance_from_project_costs +from pabutools.election.profile import AbstractProfile, Profile, approval_profile_from_matrix +from pabutools.election.satisfaction import AdditiveSatisfaction +from pabutools.rules.gcr import greedy_cohesive_rule +from pabutools.rules.mes import method_of_equal_shares + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Convenience aliases kept for backward compatibility +# --------------------------------------------------------------------------- + +def build_instance(C: list, cost: dict, B: float) -> Instance: + """Alias for :func:`~pabutools.election.instance.instance_from_project_costs`.""" + return instance_from_project_costs({c: cost[c] for c in C}, B) + + +def build_profile(N: list, ui: dict, instance: Instance) -> Profile: + """Alias for :func:`~pabutools.election.profile.approval_profile_from_matrix`.""" + return approval_profile_from_matrix(N, ui, instance) + + +def approval_sat(instance: Instance, profile: Profile, ballot: ApprovalBallot): + """ + Approval-based satisfaction function for use with MES. + + A voter receives satisfaction 1 from a project that appears in their ballot, + and 0 otherwise. + """ + def f(inst, prof, bal, project, *rest): + return 1 if project in ballot else 0 + return AdditiveSatisfaction(instance, profile, ballot, func=f) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _gnz_calc(A_Nz_sorted: list, B: float, n: int, group_size: int) -> list: + """Return the greedy set G_Nz of projects the group can afford collectively.""" + group_budget = group_size * (B / n) + G_Nz = [] + spent = 0.0 + for proj in A_Nz_sorted: + if spent + proj.cost <= group_budget: + G_Nz.append(proj) + spent += proj.cost + else: + break + return G_Nz + + +# --------------------------------------------------------------------------- +# BB1 dependent rounding +# --------------------------------------------------------------------------- + +def dependent_rounding_bb1(p_vec_list: list, projects: list) -> list: + """ + Dependent Randomized Rounding (BB1) — converts a fractional probability + vector into a discrete set of projects. + + This guarantees ex-post Budget Balance up to 1 project (BB1). + + Parameters + ---------- + p_vec_list : list[float] + Fractional probabilities, one per project, in the same order as `projects`. + projects : list[Project] + Ordered list of Project objects. + + Returns + ------- + list[Project] + The selected projects after rounding. + + Examples + -------- + (All examples use deterministic probabilities of 1.0 or 0.0.) + + Example 1: mix of 1.0 and 0.0 + >>> pa = Project("a", 12000); pb = Project("b", 12000); pc = Project("c", 8000); pd = Project("d", 8000) + >>> dependent_rounding_bb1([1.0, 1.0, 1.0, 0.0], [pa, pb, pc, pd]) + [a, b, c] + + Example 2: all 1.0 + >>> pa = Project("a", 21000); pb = Project("b", 10000); pc = Project("c", 2000) + >>> dependent_rounding_bb1([1.0, 1.0, 1.0], [pa, pb, pc]) + [a, b, c] + + Example 3: all 0.0 + >>> pa = Project("a", 21000); pb = Project("b", 10000); pc = Project("c", 2000) + >>> dependent_rounding_bb1([0.0, 0.0, 0.0], [pa, pb, pc]) + [] + """ + logger.info("Starting BB1 dependent rounding with %d projects.", len(projects)) + p = {projects[i]: p_vec_list[i] for i in range(len(projects))} + iteration = 1 + + while True: + fractional = [proj for proj, prob in p.items() if 0.0001 < prob < 0.9999] + + if not fractional: + logger.info("BB1 complete in %d iterations.", iteration) + break + + if len(fractional) == 1: + proj = fractional[0] + logger.info("Single fractional project %s (%.4f). Rounding independently.", proj, p[proj]) + p[proj] = 1.0 if random.random() < p[proj] else 0.0 + break + + pi, pj = fractional[0], fractional[1] + logger.debug("Iteration %d: pair %s (%.4f) and %s (%.4f).", iteration, pi, p[pi], pj, p[pj]) + + # Option A: increase pi, decrease pj + alpha = min(1.0 - p[pi], p[pj] * (pj.cost / pi.cost)) + beta = alpha * (pi.cost / pj.cost) + + # Option B: decrease pi, increase pj + gamma = min(p[pi], (1.0 - p[pj]) * (pj.cost / pi.cost)) + delta = gamma * (pi.cost / pj.cost) + + q = gamma / (alpha + gamma) if (alpha + gamma) > 0 else 0.0 + + if random.random() < q: + p[pi] += alpha + p[pj] -= beta + else: + p[pi] -= gamma + p[pj] += delta + + iteration += 1 + + W = [proj for proj, prob in p.items() if prob >= 0.9999] + logger.info("BB1 finished. Selected %d projects.", len(W)) + return W + + +# --------------------------------------------------------------------------- +# Algorithm 1: BW-GCR-PB +# --------------------------------------------------------------------------- + +def BW_GCR_PB(instance: Instance, profile: AbstractProfile, analytics: bool = False) -> list: + """ + Algorithm 1 (BW-GCR-PB): ballot-weighted lottery backed by the Greedy + Cohesive Rule (GCR). + + Satisfies strong UFS and Fair Justified Representation (FJR). + + Parameters + ---------- + instance : Instance + The PB instance (projects + budget limit). + profile : AbstractProfile + The voters' approval ballots. + analytics : bool, optional + Reserved for future use. Defaults to False. + + Returns + ------- + list[float] + Fractional probability for each project, sorted alphabetically by name. + """ + n = profile.num_ballots() + B = instance.budget_limit + projects = sorted(instance, key=lambda p: p.name) + ballots = list(profile) + + logger.info("BW_GCR_PB: %d voters, %d projects, budget %f.", n, len(projects), B) + + try: + gcr_allocation = greedy_cohesive_rule(instance=instance, profile=profile) + selected = set(gcr_allocation) + logger.info("GCR selected %d projects.", len(selected)) + except Exception as e: + logger.error("GCR failed: %s", e) + selected = set() + + # Line 2: initialize probability vector + p_vec = {proj: (1.0 if proj in selected else 0.0) for proj in projects} + + # Lines 3-4: track per-voter budget allocations + b = {i: 0.0 for i in range(n)} + N_tilde: set = set() + + # Line 5: group voters by identical approval set + groups: dict = {} + for idx, ballot in enumerate(ballots): + key = frozenset(ballot) + groups.setdefault(key, []).append(idx) + + logger.info("Identified %d unanimous groups.", len(groups)) + + # Line 6: process each unanimous group + for group_idx, (approved_set, group_indices) in enumerate(groups.items()): + group_size = len(group_indices) + A_Nz = list(approved_set) + A_Nz_sorted = sorted(A_Nz, key=lambda proj: (proj.cost, proj.name)) + G_Nz = _gnz_calc(A_Nz_sorted, B, n, group_size) + + # Line 7: check intersection condition + intersect = [proj for proj in A_Nz if proj in selected] + if len(intersect) != len(G_Nz): + continue + + # Lines 8-9 + N_tilde.update(group_indices) + cost_G = sum(proj.cost for proj in G_Nz) + for i in group_indices: + b[i] = (B / n) - (1 / group_size) * cost_G + + # Line 10: spend leftover on cheapest approved projects + leftover = group_size * (B / n) - cost_G + logger.debug("Group %d leftover budget: %f.", group_idx + 1, leftover) + + for proj in A_Nz_sorted: + if p_vec[proj] < 1.0 and leftover > 0: + increase = min(1.0 - p_vec[proj], leftover / proj.cost) + p_vec[proj] += increase + leftover -= increase * proj.cost + + # Line 11: fill remaining budget arbitrarily + expected_cost = sum(p_vec[proj] * proj.cost for proj in projects) + remaining = B - expected_cost + logger.info("After groups: expected cost %f, remaining %f.", expected_cost, remaining) + + if remaining < -0.0001: + logger.warning("Remaining budget is negative (%f).", remaining) + + for proj in projects: + if remaining <= 0.0001: + break + if p_vec[proj] < 1.0: + headroom = 1.0 - p_vec[proj] + needed = headroom * proj.cost + if remaining >= needed: + p_vec[proj] = 1.0 + remaining -= needed + else: + p_vec[proj] += remaining / proj.cost + remaining = 0 + + logger.info("BW_GCR_PB completed.") + return [p_vec[proj] for proj in projects] + + +def BW_GCR_PB_from_lists(N: list, C: list, cost: dict, B: float, ui: dict) -> list: + """ + Convenience wrapper for :func:`BW_GCR_PB` that accepts raw lists and dicts. + + Parameters + ---------- + N : list + Voter identifiers. + C : list + Project identifiers (must be in a consistent order — returned probabilities + match this order). + cost : dict + Maps each project identifier to its cost. + B : float + Total available budget. + ui : dict + Maps each voter to a dict ``{project_id: 1/0}``. + + Returns + ------- + list[float] + Fractional probability for each project, in the same order as `C`. + + Examples + -------- + Example 1: Enough budget for all projects: + >>> N = ['1', '2'] + >>> C = ['a', 'b', 'c'] + >>> cost = {'a': 21000, 'b': 10000, 'c': 2000} + >>> B = 33000 + >>> ui = { + ... '1': {'a': 1, 'b': 1, 'c': 0}, + ... '2': {'a': 0, 'b': 1, 'c': 1} + ... } + >>> BW_GCR_PB_from_lists(N, C, cost, B, ui) + [1.0, 1.0, 1.0] + + Example 2: Different output for each algorithm: + >>> N = ['1', '2', '3'] + >>> C = ['a', 'b', 'c', 'd'] + >>> cost = {'a': 8000, 'b': 8000, 'c': 12000, 'd': 12000} + >>> B = 30000 + >>> ui = { + ... '1': {'a': 1, 'b': 0, 'c': 1, 'd': 0}, + ... '2': {'a': 0, 'b': 0, 'c': 1, 'd': 1}, + ... '3': {'a': 0, 'b': 1, 'c': 0, 'd': 1} + ... } + >>> BW_GCR_PB_from_lists(N, C, cost, B, ui) + [1.0, 1.0, 1.0, 0.16666666666666666] + + Example 3: tight budget: + >>> N = ['1', '2', '3', '4'] + >>> C = ['a', 'b'] + >>> cost = {'a': 1000, 'b': 5000} + >>> B = 5000 + >>> ui = { + ... '1': {'a': 1, 'b': 1}, + ... '2': {'a': 0, 'b': 1}, + ... '3': {'a': 0, 'b': 1}, + ... '4': {'a': 0, 'b': 1} + ... } + >>> BW_GCR_PB_from_lists(N, C, cost, B, ui) + [1.0, 0.8] + + Example 4: many projects (GCR output varies with set iteration order): + >>> N = ['1', '2', '3', '4', '5', '6', '7', '8'] + >>> C = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] + >>> cost = {'a': 8000, 'b': 15000, 'c': 10000, 'd': 10000, 'e': 6000, 'f': 12000, 'g': 9000, 'h': 9000, 'i': 5000, 'j': 5000} + >>> B = 80000 + >>> ui = { + ... '1': {'a': 1, 'b': 1, 'c': 1, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '2': {'a': 1, 'b': 1, 'c': 1, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '3': {'a': 1, 'b': 1, 'c': 0, 'd': 1, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '4': {'a': 1, 'b': 1, 'c': 0, 'd': 1, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '5': {'a': 1, 'b': 1, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '6': {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 1, 'f': 1, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '7': {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 1, 'h': 1, 'i': 0, 'j': 0}, + ... "8": {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 1, 'j': 1} + ... } + >>> result = BW_GCR_PB_from_lists(N, C, cost, B, ui) + >>> abs(sum(p * cost[c] for p, c in zip(result, C)) - B) < 0.01 + True + >>> all(0.0 <= p <= 1.0 for p in result) + True + + Example 5: not covering all code lines: + >>> N = ['1', '2', '3'] + >>> C = ['a', 'b', 'c'] + >>> cost = {'a': 5000, 'b': 5000, 'c': 6000} + >>> B = 15000 + >>> ui = { + ... '1': {'a': 1, 'b': 1, 'c': 1}, + ... '2': {'a': 1, 'b': 1, 'c': 1}, + ... '3': {'a': 1, 'b': 1, 'c': 0} + ... } + >>> BW_GCR_PB_from_lists(N, C, cost, B, ui) + [1.0, 1.0, 0.8333333333333334] + """ + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + projects_sorted = sorted(instance, key=lambda p: p.name) + probs = BW_GCR_PB(instance, profile) + name_to_prob = {proj.name: prob for proj, prob in zip(projects_sorted, probs)} + return [name_to_prob[c] for c in C] + + +# --------------------------------------------------------------------------- +# Algorithm 2: BW-MES-PB +# --------------------------------------------------------------------------- + +def BW_MES_PB(instance: Instance, profile: AbstractProfile, analytics: bool = False) -> list: + """ + Algorithm 2 (BW-MES-PB): ballot-weighted lottery backed by the Method of + Equal Shares (MES). + + Satisfies strong UFS and Extended Justified Representation (EJR). + + Parameters + ---------- + instance : Instance + The PB instance (projects + budget limit). + profile : AbstractProfile + The voters' approval ballots. + analytics : bool, optional + Reserved for future use. Defaults to False. + + Returns + ------- + list[float] + Fractional probability for each project, sorted alphabetically by name. + """ + n = profile.num_ballots() + B = instance.budget_limit + projects = sorted(instance, key=lambda p: p.name) + ballots = list(profile) + + logger.info("BW_MES_PB: %d voters, %d projects, budget %f.", n, len(projects), B) + + # Line 1: run MES + allocation = None + try: + allocation = method_of_equal_shares( + instance, profile, sat_class=approval_sat, analytics=True + ) + W: set = set(allocation) + logger.info("MES selected %d projects.", len(W)) + except Exception as e: + logger.error("MES failed: %s", e) + W = set() + + # Line 2: initialize probability vector + p_vec = {proj: (1.0 if proj in W else 0.0) for proj in projects} + + # Lines 3-4: remaining budget per voter after MES + budget_per_voter = B / n + remaining = {i: budget_per_voter for i in range(n)} + + if allocation is not None and allocation.details and allocation.details.iterations: + for iteration in reversed(allocation.details.iterations): + if iteration.selected_project is not None: + for idx in range(n): + remaining[idx] = iteration.voters_budget_after_selection[idx] + break + + # Line 5: N_prime — voters with budget who approve an unselected project + N_prime = [ + i for i in range(n) + if remaining[i] > 0 and any(proj not in W and proj in ballots[i] for proj in projects) + ] + logger.info("N_prime size: %d.", len(N_prime)) + + # Lines 6-8: each N_prime voter spends on approved unselected projects + for i in N_prime: + liked = sorted( + [proj for proj in projects if proj not in W and proj in ballots[i]], + key=lambda proj: (proj.cost, proj.name) + ) + for proj in liked: + if remaining[i] <= 0: + break + needed = proj.cost * (1 - p_vec[proj]) + if needed <= 0: + continue + payment = min(remaining[i], needed) + remaining[i] -= payment + p_vec[proj] += payment / proj.cost + + # Lines 9-10: N_minus voters dump leftover on first unselected project + N_prime_set = set(N_prime) + N_minus = [i for i in range(n) if i not in N_prime_set] + unselected = [proj for proj in projects if proj not in W] + + if unselected: + first = unselected[0] + total = sum(remaining[i] for i in N_minus) + if total > 0: + needed = first.cost * (1 - p_vec[first]) + if needed > 0: + payment = min(total, needed) + p_vec[first] += payment / first.cost + for i in N_minus: + remaining[i] = 0 + + # Line 11: normalize near-1 probabilities + EPS = 1e-9 + for proj in projects: + if p_vec[proj] >= 1 - EPS: + p_vec[proj] = 1.0 + W.add(proj) + + logger.info("BW_MES_PB completed.") + return [float(p_vec[proj]) for proj in projects] + + +def BW_MES_PB_from_lists(N: list, C: list, cost: dict, B: float, ui: dict) -> list: + """ + Convenience wrapper for :func:`BW_MES_PB` that accepts raw lists and dicts. + + Parameters + ---------- + N : list + Voter identifiers. + C : list + Project identifiers. + cost : dict + Maps each project identifier to its cost. + B : float + Total available budget. + ui : dict + Maps each voter to a dict ``{project_id: 1/0}``. + + Returns + ------- + list[float] + Fractional probability for each project, in the same order as `C`. + + Examples + -------- + Example 1: Enough budget for all projects: + >>> N = ['1', '2'] + >>> C = ['a', 'b', 'c'] + >>> cost = {'a': 21000, 'b': 10000, 'c': 2000} + >>> B = 33000 + >>> ui = { + ... '1': {'a': 1, 'b': 1, 'c': 0}, + ... '2': {'a': 0, 'b': 1, 'c': 1} + ... } + >>> BW_MES_PB_from_lists(N, C, cost, B, ui) + [1.0, 1.0, 1.0] + + Example 2: Different output for each algorithm: + >>> N = ['1', '2', '3'] + >>> C = ['a', 'b', 'c', 'd'] + >>> cost = {'a': 8000, 'b': 8000, 'c': 12000, 'd': 12000} + >>> B = 30000 + >>> ui = { + ... '1': {'a': 1, 'b': 0, 'c': 1, 'd': 0}, + ... '2': {'a': 0, 'b': 0, 'c': 1, 'd': 1}, + ... '3': {'a': 0, 'b': 1, 'c': 0, 'd': 1} + ... } + >>> BW_MES_PB_from_lists(N, C, cost, B, ui) + [0.5, 1.0, 1.0, 0.5] + + Example 3: tight budget: + >>> N = ['1', '2', '3', '4'] + >>> C = ['a', 'b'] + >>> cost = {'a': 1000, 'b': 5000} + >>> B = 5000 + >>> ui = { + ... '1': {'a': 1, 'b': 1}, + ... '2': {'a': 0, 'b': 1}, + ... '3': {'a': 0, 'b': 1}, + ... '4': {'a': 0, 'b': 1} + ... } + >>> BW_MES_PB_from_lists(N, C, cost, B, ui) + [1.0, 0.8] + + Example 4: many projects and voters: + >>> N = ['1', '2', '3', '4', '5', '6', '7', '8'] + >>> C = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] + >>> cost = {'a': 8000, 'b': 15000, 'c': 10000, 'd': 10000, 'e': 6000, 'f': 12000, 'g': 9000, 'h': 9000, 'i': 5000, 'j': 5000} + >>> B = 80000 + >>> ui = { + ... '1': {'a': 1, 'b': 1, 'c': 1, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '2': {'a': 1, 'b': 1, 'c': 1, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '3': {'a': 1, 'b': 1, 'c': 0, 'd': 1, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '4': {'a': 1, 'b': 1, 'c': 0, 'd': 1, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '5': {'a': 1, 'b': 1, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '6': {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 1, 'f': 1, 'g': 0, 'h': 0, 'i': 0, 'j': 0}, + ... '7': {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 1, 'h': 1, 'i': 0, 'j': 0}, + ... "8": {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0, 'h': 0, 'i': 1, 'j': 1} + ... } + >>> BW_MES_PB_from_lists(N, C, cost, B, ui) + [1.0, 1.0, 1.0, 1.0, 1.0, 0.9166666666666667, 1.0, 0.1111111111111111, 1.0, 1.0] + """ + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + projects_sorted = sorted(instance, key=lambda p: p.name) + probs = BW_MES_PB(instance, profile) + name_to_prob = {proj.name: prob for proj, prob in zip(projects_sorted, probs)} + return [name_to_prob[c] for c in C] + + +# --------------------------------------------------------------------------- +# Wrapped entry points (validation + BB1 rounding) +# --------------------------------------------------------------------------- + +def _generic_pb_wrapper( + algo_func, + instance: Instance, + profile: AbstractProfile, +) -> tuple[list, list]: + """ + Validate inputs, run the core algorithm, and apply BB1 dependent rounding. + + Parameters + ---------- + algo_func : callable + :func:`BW_GCR_PB` or :func:`BW_MES_PB`. + instance : Instance + profile : AbstractProfile + + Returns + ------- + tuple[list[float], list[Project]] + The fractional probability vector and the discrete project selection. + + Raises + ------ + ValueError + If any input is None, the wrong type, or empty. + """ + logger.info("Starting wrapped execution for %s.", algo_func.__name__) + + if instance is None or profile is None: + logger.critical("instance or profile is None.") + raise ValueError("One or more of the parameters is null") + + if not isinstance(instance, Instance): + logger.error("instance expected Instance, got %s.", type(instance)) + raise ValueError("Parameter instance is not of the expected type") + if not isinstance(profile, AbstractProfile): + logger.error("profile expected AbstractProfile, got %s.", type(profile)) + raise ValueError("Parameter profile is not of the expected type") + + if len(instance) == 0 or profile.num_ballots() == 0 or instance.budget_limit == 0: + logger.critical("instance or profile is empty / budget is 0.") + raise ValueError("One or more of the parameters is empty") + + p_vec = algo_func(instance, profile) + projects = sorted(instance, key=lambda p: p.name) + final_proj = dependent_rounding_bb1(p_vec, projects) + + logger.info("%s finished. Selected %d projects.", algo_func.__name__, len(final_proj)) + return p_vec, final_proj + + +def BW_GCR_PB_wrapped(instance: Instance, profile: AbstractProfile) -> tuple[list, list]: + """ + Run :func:`BW_GCR_PB` with input validation and BB1 dependent rounding. + + Parameters + ---------- + instance : Instance + profile : AbstractProfile + + Returns + ------- + tuple[list[float], list[Project]] + Probability vector and discrete project selection. + """ + return _generic_pb_wrapper(BW_GCR_PB, instance, profile) + + +def BW_MES_PB_wrapped(instance: Instance, profile: AbstractProfile) -> tuple[list, list]: + """ + Run :func:`BW_MES_PB` with input validation and BB1 dependent rounding. + + Parameters + ---------- + instance : Instance + profile : AbstractProfile + + Returns + ------- + tuple[list[float], list[Project]] + Probability vector and discrete project selection. + """ + return _generic_pb_wrapper(BW_MES_PB, instance, profile) diff --git a/tests/rules/test_gcr.py b/tests/rules/test_gcr.py new file mode 100644 index 00000000..ce71cbe5 --- /dev/null +++ b/tests/rules/test_gcr.py @@ -0,0 +1,206 @@ +""" +Tests for the Greedy Cohesive Rule (GCR) implementation. + +Each test verifies a specific property of GCR: + - Output is a feasible budget allocation + - FJR: every (β,T)-cohesive group has ≥ β approved projects selected + - Edge cases: no preferences, full budget available, single project +""" + +from unittest import TestCase + +from pabutools.election.ballot import ApprovalBallot +from pabutools.election.instance import Instance, Project, total_cost +from pabutools.election.profile import ApprovalProfile +from pabutools.rules.budgetallocation import BudgetAllocation +from pabutools.rules.gcr import GCRAllocationDetails, greedy_cohesive_rule + + +def check_fjr(instance, profile, allocation): + """ + Returns True if the allocation satisfies Fair Justified Representation (FJR). + + FJR: for every positive integer β and every set T such that a group N' + of active voters is weakly (β,T)-cohesive (all voters approve all of T, + and |N'|·B ≥ β·|N|·cost(T)), at least ONE voter i ∈ N' must have + ≥ β approved projects in the selected allocation. + """ + n = profile.num_ballots() + B = instance.budget_limit + ballots = list(profile) + projects = list(instance) + + # Pre-compute each voter's satisfaction (# approved projects selected) + voter_sat = [ + sum(1 for p in allocation if p in ballots[i]) + for i in range(n) + ] + + from itertools import combinations + + for r in range(1, len(projects) + 1): + for T in combinations(projects, r): + cost_T = total_cost(T) + if cost_T <= 0: + continue + # N'(T) = voters who approve every project in T + N_prime = [i for i in range(n) if all(p in ballots[i] for p in T)] + if not N_prime: + continue + beta = int(len(N_prime) * B / (n * cost_T)) + if beta < 1: + continue + # FJR: at least one voter in N' has satisfaction >= beta + if not any(voter_sat[i] >= beta for i in N_prime): + return False + return True + + +class TestGreedyCohesiveRule(TestCase): + + def _make_instance(self, costs, budget): + projects = [Project(name, cost) for name, cost in costs.items()] + return Instance(projects, budget_limit=budget) + + def _make_profile(self, instance, ballots_spec): + """ballots_spec: list of sets of project names""" + proj_map = {p.name: p for p in instance} + ballots = [ + ApprovalBallot([proj_map[name] for name in names]) + for names in ballots_spec + ] + return ApprovalProfile(ballots, instance=instance) + + # ------------------------------------------------------------------ + # Basic feasibility tests + # ------------------------------------------------------------------ + + def test_output_is_budget_allocation(self): + instance = self._make_instance({"a": 30, "b": 30}, budget=60) + profile = self._make_profile(instance, [{"a"}, {"b"}]) + result = greedy_cohesive_rule(instance, profile) + self.assertIsInstance(result, BudgetAllocation) + + def test_allocation_is_feasible(self): + instance = self._make_instance({"a": 30, "b": 40, "c": 20}, budget=60) + profile = self._make_profile(instance, [{"a", "b"}, {"b", "c"}, {"a", "c"}]) + result = greedy_cohesive_rule(instance, profile) + self.assertTrue(instance.is_feasible(result)) + + # ------------------------------------------------------------------ + # Correctness: simple hand-verifiable cases + # ------------------------------------------------------------------ + + def test_unanimous_single_project(self): + """All voters approve the same project; it must be selected.""" + instance = self._make_instance({"p1": 50}, budget=100) + profile = self._make_profile(instance, [{"p1"}, {"p1"}, {"p1"}]) + result = greedy_cohesive_rule(instance, profile) + names = {p.name for p in result} + self.assertIn("p1", names) + + def test_two_disjoint_groups(self): + """ + Two equally-sized disjoint groups with equal-cost projects. + GCR should select both (one project per group, budget covers both). + """ + instance = self._make_instance({"a": 30, "b": 30}, budget=60) + profile = self._make_profile(instance, [{"a"}, {"a"}, {"b"}, {"b"}]) + result = greedy_cohesive_rule(instance, profile) + names = {p.name for p in result} + self.assertIn("a", names) + self.assertIn("b", names) + + def test_larger_group_wins_tiebreak(self): + """ + Two projects with equal cost; one approved by 3 of 4 voters, other by 1. + Budget is 80, so the majority group (3 voters) is barely β=1 cohesive + (3*80 = 240 >= 1*4*60 = 240), while the minority (1 voter) is not. + GCR must select 'maj'. + """ + instance = self._make_instance({"maj": 60, "min": 60}, budget=80) + profile = self._make_profile( + instance, [{"maj"}, {"maj"}, {"maj"}, {"min"}] + ) + result = greedy_cohesive_rule(instance, profile) + names = {p.name for p in result} + self.assertIn("maj", names) + + def test_empty_approval_selects_nothing(self): + """When no voter approves anything, GCR returns an empty allocation.""" + instance = self._make_instance({"a": 10, "b": 10}, budget=100) + profile = self._make_profile(instance, [set(), set()]) + result = greedy_cohesive_rule(instance, profile) + self.assertEqual(len(result), 0) + + def test_two_disjoint_voters_both_selected(self): + """ + Two voters with disjoint preferences, budget covers both projects. + GCR selects x first (higher β), deactivates voter 0, then selects y. + Both projects end up in the allocation. + """ + instance = self._make_instance({"x": 10, "y": 20}, budget=100) + profile = self._make_profile(instance, [{"x"}, {"y"}]) + result = greedy_cohesive_rule(instance, profile) + names = {p.name for p in result} + self.assertIn("x", names) + self.assertIn("y", names) + + # ------------------------------------------------------------------ + # FJR property + # ------------------------------------------------------------------ + + def test_fjr_three_voters_three_projects(self): + """GCR output satisfies FJR on a small instance.""" + instance = self._make_instance({"a": 10, "b": 10, "c": 10}, budget=20) + profile = self._make_profile( + instance, + [{"a", "b"}, {"a", "c"}, {"b", "c"}], + ) + result = greedy_cohesive_rule(instance, profile) + self.assertTrue(check_fjr(instance, profile, result)) + + def test_fjr_disjoint_groups(self): + instance = self._make_instance({"a": 30, "b": 30, "c": 30}, budget=60) + profile = self._make_profile( + instance, + [{"a"}, {"a"}, {"b"}, {"b"}, {"c"}], + ) + result = greedy_cohesive_rule(instance, profile) + self.assertTrue(check_fjr(instance, profile, result)) + + # ------------------------------------------------------------------ + # Analytics + # ------------------------------------------------------------------ + + def test_analytics_structure(self): + instance = self._make_instance({"a": 30, "b": 30}, budget=60) + profile = self._make_profile(instance, [{"a"}, {"a"}, {"b"}, {"b"}]) + result = greedy_cohesive_rule(instance, profile, analytics=True) + self.assertIsInstance(result.details, GCRAllocationDetails) + self.assertGreater(len(result.details.iterations), 0) + first = result.details.iterations[0] + self.assertGreaterEqual(first.beta, 1) + self.assertIsInstance(first.selected_projects, list) + self.assertIsInstance(first.deactivated_voters, list) + + def test_analytics_none_without_flag(self): + instance = self._make_instance({"a": 30}, budget=60) + profile = self._make_profile(instance, [{"a"}]) + result = greedy_cohesive_rule(instance, profile, analytics=False) + self.assertIsNone(result.details) + + # ------------------------------------------------------------------ + # max_subset_size parameter + # ------------------------------------------------------------------ + + def test_max_subset_size_one_still_feasible(self): + """With max_subset_size=1, output must still be feasible.""" + instance = self._make_instance( + {"a": 20, "b": 20, "c": 20}, budget=40 + ) + profile = self._make_profile( + instance, [{"a", "b"}, {"a", "c"}, {"b", "c"}] + ) + result = greedy_cohesive_rule(instance, profile, max_subset_size=1) + self.assertTrue(instance.is_feasible(result)) diff --git a/tests/test_bw_algorithms.py b/tests/test_bw_algorithms.py new file mode 100644 index 00000000..f81625c4 --- /dev/null +++ b/tests/test_bw_algorithms.py @@ -0,0 +1,187 @@ +""" +test for the algorithms in: +"lottery_rule" +by Haris Aziz, Xinhang Lu, Mashbat Suzuki, Jeremy Vollen, Toby Walsh (2024) +https://ojs.aaai.org/index.php/AAAI/article/view/28801 + +Programmers: Dotan Danino, Naama Yahav. +Date: 19/4/2026 + +""" + +import numpy as np +import random +import unittest +from pabutools.election.instance import Instance, Project +from pabutools.election.profile import Profile +from pabutools.rules.lottery import ( + BW_GCR_PB_wrapped, + BW_MES_PB_wrapped, + build_instance, + build_profile, +) +from pabutools.analysis.justifiedrepresentation import ( + is_FJR_approval, + is_EJR_approval, + is_strong_UFS_approval, +) +from pabutools.election.satisfaction import Cardinality_Sat + + +class TestAlgorithms(unittest.TestCase): + + def test_raise(self): + # Empty instance (no projects) + instance = Instance([], budget_limit=33000) + profile = Profile([]) + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped(instance, profile) + with self.assertRaises(ValueError): + BW_MES_PB_wrapped(instance, profile) + + # budget_limit = 0 + p = Project('a', 21000) + instance_b0 = Instance([p], budget_limit=0) + profile_b0 = Profile([]) + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped(instance_b0, profile_b0) + with self.assertRaises(ValueError): + BW_MES_PB_wrapped(instance_b0, profile_b0) + + def test_none_raise(self): + N = ['1', '2'] + C = ['a', 'b', 'c'] + cost = {'a': 21000, 'b': 10000, 'c': 2000} + B = 33000.0 + ui = { + '1': {'a': 1, 'b': 1, 'c': 0}, + '2': {'a': 0, 'b': 1, 'c': 1} + } + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped(None, profile) + with self.assertRaises(ValueError): + BW_MES_PB_wrapped(None, profile) + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped(instance, None) + with self.assertRaises(ValueError): + BW_MES_PB_wrapped(instance, None) + + def test_annotation_raise(self): + N = ['1', '2'] + C = ['a', 'b', 'c'] + cost = {'a': 21000, 'b': 10000, 'c': 2000} + B = 33000.0 + ui = { + '1': {'a': 1, 'b': 1, 'c': 0}, + '2': {'a': 0, 'b': 1, 'c': 1} + } + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped("not_an_instance", profile) + with self.assertRaises(ValueError): + BW_MES_PB_wrapped("not_an_instance", profile) + with self.assertRaises(ValueError): + BW_GCR_PB_wrapped(instance, "not_a_profile") + with self.assertRaises(ValueError): + BW_MES_PB_wrapped(instance, "not_a_profile") + + def test_not_exceed_budget(self): + N = list(np.arange(1, random.randint(10, 100))) + C = list(np.arange(1, random.randint(10, 100))) + cost = {c: random.randint(1, 1000) for c in C} + B = float(random.randint(1000, 20000)) + ui = {n: {c: random.randint(0, 1) for c in C} for n in N} + + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + p2, s2 = BW_MES_PB_wrapped(instance, profile) + + if s2: + total_cost_s2 = sum(proj.cost for proj in s2) + self.assertLessEqual( + total_cost_s2, + B + max(proj.cost for proj in s2), + msg=f"MES exceeded budget: total_cost={total_cost_s2}, budget={B}" + ) + + def test_not_exceed_budget_GCR(self): + N = list(np.arange(1, random.randint(3, 5))) + C = list(np.arange(1, random.randint(6, 10))) + cost = {c: random.randint(1, 1000) for c in C} + B = float(random.randint(100, 600)) + ui = {n: {c: random.randint(0, 1) for c in C} for n in N} + + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + p1, s1 = BW_GCR_PB_wrapped(instance, profile) + + if s1: + total_cost_s1 = sum(proj.cost for proj in s1) + self.assertLessEqual( + total_cost_s1, + B + max(proj.cost for proj in s1), + msg=f"GCR exceeded budget: total_cost={total_cost_s1}, budget={B}" + ) + + def test_Many_Projects_Many_Citizens(self): + N = list(np.arange(1, random.randint(10, 100))) + C = list(np.arange(1, random.randint(10, 100))) + cost = {c: random.randint(1, 1000) for c in C} + B = float(random.randint(1000, 20000)) + ui = {n: {c: random.randint(0, 1) for c in C} for n in N} + + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + p2, s2 = BW_MES_PB_wrapped(instance, profile) + + # p2 is aligned with projects sorted by name (see BW_MES_PB_wrapped) + sorted_projects = sorted(instance, key=lambda p: p.name) + fractional_allocation = dict(zip(sorted_projects, p2)) + + self.assertTrue( + is_strong_UFS_approval( + instance, profile, Cardinality_Sat, fractional_allocation, sample_size=50 + ), + msg="MES failed strong UFS" + ) + + def test_EJR_MES(self): + N = list(np.arange(1, random.randint(10, 40))) + C = list(np.arange(1, random.randint(10, 40))) + cost = {c: random.randint(1, 100) for c in C} + B = float(random.randint(500, 5000)) + ui = {n: {c: random.randint(0, 1) for c in C} for n in N} + + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + p, s = BW_MES_PB_wrapped(instance, profile) + + self.assertTrue( + is_EJR_approval(instance, profile, Cardinality_Sat, s, sample_size=50), + msg="EJR failed" + ) + + def test_FJR_GCR(self): + N = list(np.arange(1, random.randint(3, 5))) + C = list(np.arange(1, random.randint(6, 10))) + cost = {c: random.randint(1, 100) for c in C} + B = float(random.randint(20, 100)) + ui = {n: {c: random.randint(0, 1) for c in C} for n in N} + + instance = build_instance(C, cost, B) + profile = build_profile(N, ui, instance) + p, s = BW_GCR_PB_wrapped(instance, profile) + + self.assertTrue( + is_FJR_approval(instance, profile, Cardinality_Sat, s), + msg="FJR failed" + ) + + +if __name__ == '__main__': + unittest.main()