Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,5 @@ cython_debug/
analysis/Pabulib

.vscode/
.python-version
.DS_Store
1 change: 1 addition & 0 deletions pabutools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
import logging

logging.getLogger('pabutools').addHandler(logging.NullHandler())

7 changes: 5 additions & 2 deletions pabutools/analysis/priceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import collections
from collections.abc import Collection
import logging

logger = logging.getLogger(__name__)

from pabutools.analysis.priceability_relaxation import Relaxation
from pabutools.election import (
Expand Down Expand Up @@ -155,7 +158,7 @@ def validate_price_system(

if verbose:
for condition, error in errors.items():
print(f"({condition}) {error}")
logger.info("(%s) %s", condition, error)

return not errors

Expand Down Expand Up @@ -421,4 +424,4 @@ def priceable(
relaxation.get_beta() if relaxation is not None else None
),
payment_functions=payment_functions,
)
)
18 changes: 10 additions & 8 deletions pabutools/rules/cstv.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
from pabutools.utils import Numeric

import pabutools.fractions
import logging

logger = logging.getLogger(__name__)

###################################################################
# #
Expand Down Expand Up @@ -196,7 +198,7 @@ def cstv(
# Calculate the total budget
budget = sum(sum(donor.values()) for donor in donations)
if verbose:
print(f"Budget is: {budget}")
logger.info(f"Budget is: {budget}")

# Halting condition: if there are no more projects to consider
if not current_projects:
Expand All @@ -210,21 +212,21 @@ def cstv(
tie_breaking,
)
if verbose:
print(f"Final selected projects: {selected_projects}")
logger.info(f"Final selected projects: {selected_projects}")
return selected_projects

# Log donations for each project
if verbose:
for project in current_projects:
total_donation = sum(donor[project] for donor in donations)
print(
logger.info(
f"Donors and total donations for {project}: {total_donation}. Price: {project.cost}"
)

# Determine eligible projects for funding
eligible_projects = eligible_projects_func(current_projects, donations)
if verbose:
print(
logger.info(
f"Eligible projects: {eligible_projects}",
)

Expand All @@ -248,7 +250,7 @@ def cstv(
tie_breaking,
)
if verbose:
print(f"Final selected projects: {selected_projects}")
logger.info(f"Final selected projects: {selected_projects}")
return selected_projects
eligible_projects = eligible_projects_func(current_projects, donations)

Expand All @@ -262,7 +264,7 @@ def cstv(
p = tied_projects[0]
excess_support = sum(donor.get(p.name, 0) for donor in donations) - p.cost
if verbose:
print(f"Excess support for {p}: {excess_support}")
logger.info(f"Excess support for {p}: {excess_support}")

# If the project has enough or excess support
if excess_support >= 0:
Expand All @@ -273,15 +275,15 @@ def cstv(
else:
# Reset donations for the eliminated project
if verbose:
print(f"Resetting donations for eliminated project: {p}")
logger.info(f"Resetting donations for eliminated project: {p}")
for donor in donations:
donor[p] = 0

# Add the project to the selected set and remove it from further consideration
selected_projects.append(p)
current_projects.remove(p)
if verbose:
print(f"Updated selected projects: {selected_projects}")
logger.info(f"Updated selected projects: {selected_projects}")
budget -= p.cost
continue

Expand Down
2 changes: 1 addition & 1 deletion pabutools/rules/mes/mes_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,4 @@ def __str__(self):
return f"MESIteration[{[project for project in self]}]"

def __repr__(self):
return f"MESIteration[{[project for project in self]}]"
return f"MESIteration[{[project for project in self]}]"
23 changes: 13 additions & 10 deletions pabutools/rules/mes/mes_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
from pabutools.tiebreaking import TieBreakingRule, lexico_tie_breaking
from pabutools.fractions import frac

import logging
logger = logging.getLogger(__name__)


class MESVoter:
"""
Expand Down Expand Up @@ -342,16 +345,16 @@ def mes_inner_algo(
current_iteration.voters_budget = [voter.budget for voter in voters]
best_afford = float("inf")
if verbose:
print("========================")
logger.info("========================")
for project in sorted(projects, key=lambda p: p.affordability):
if verbose:
print(f"\tConsidering: {project}")
logger.info(f"\tConsidering: {project}")
available_budget = sum(
voters[i].total_budget() for i in project.supporter_indices
)
if available_budget < project.cost: # unaffordable, can delete
if verbose:
print(
logger.info(
f"\t\t Removed for lack of budget: "
f"{float(available_budget)} < {float(project.cost)}"
)
Expand All @@ -363,7 +366,7 @@ def mes_inner_algo(
project.affordability > best_afford
): # best possible afford for this round isn't good enough
if verbose:
print(
logger.info(
f"\t\t Skipped as affordability is too high: {float(project.affordability)} > {float(best_afford)}"
)
break
Expand All @@ -376,7 +379,7 @@ def mes_inner_algo(
supporter = voters[i]
afford_factor = frac(project.cost - current_contribution, denominator)
if verbose:
print(
logger.info(
f"\t\t\t {project.cost} - {current_contribution} / {denominator} = {afford_factor} * "
f"{project.supporters_sat(supporter)} ?? {supporter.budget}"
)
Expand All @@ -391,10 +394,10 @@ def mes_inner_algo(
eff_vote_count = frac(
denominator, project.cost - current_contribution
)
print(
logger.info(
f"\t\tFactor: {float(afford_factor)} = ({float(project.cost)} - {float(current_contribution)})/{float(denominator)}"
)
print(f"\t\tEff: {float(eff_vote_count)}")
logger.info(f"\t\tEff: {float(eff_vote_count)}")
if afford_factor < best_afford:
best_afford = afford_factor
tied_projects = [project]
Expand All @@ -404,7 +407,7 @@ def mes_inner_algo(
current_contribution += supporter.total_budget()
denominator -= supporter.multiplicity * project.supporters_sat(supporter)
if verbose:
print(f"{tied_projects}")
logger.info(f"{tied_projects}")
if not tied_projects:
if analytics and skipped_project:
cover = sum(voters[i].budget for i in skipped_project.supporter_indices)
Expand Down Expand Up @@ -437,7 +440,7 @@ def mes_inner_algo(
new_alloc.append(selected_project.project)
new_projects.remove(selected_project)
if verbose:
print(
logger.info(
f"Price is {best_afford * selected_project.supporters_sat(selected_project.supporter_indices[0])}"
)
for i in selected_project.supporter_indices:
Expand Down Expand Up @@ -538,7 +541,7 @@ def method_of_equal_shares_scheme(
(:code:`resoluteness == False`).
"""
if verbose:
print(f"Initial budget per voter is: {initial_budget_per_voter}")
logger.info(f"Initial budget per voter is: {initial_budget_per_voter}")
voters = []
for index, sat in enumerate(sat_profile):
voters.append(
Expand Down
179 changes: 179 additions & 0 deletions pabutools/rules/pb_ear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@

from __future__ import annotations
from collections import defaultdict
from pabutools.election.instance import Instance
from pabutools.election.profile import AbstractProfile, OrdinalProfile
from pabutools.election.profile.ordinalprofile import AbstractOrdinalProfile
from pabutools.rules.budgetallocation import BudgetAllocation
import logging
from pabutools.utils_formatting import format_table
from pabutools.fractions import frac
from pabutools.election.instance import Project


"""
An implementation of the PB-EAR algorithm from:

"Proportionally Representative Participatory Budgeting with Ordinal Preferences",
Haris Aziz and Barton E. Lee (2020),
https://arxiv.org/abs/1911.00864v2

Programmer: Vivian Umansky
Date: 2025-04-23
"""

logger = logging.getLogger(__name__)


def pb_ear(
instance: Instance,
profile: AbstractProfile,
verbose: bool = False,
rounding_precision: int = 6
) -> BudgetAllocation:
"""
PB-EAR Algorithm — Proportional Representation via Inclusion-PSC (IPSC) under Ordinal Preferences.

This algorithm selects a subset of projects within a given budget while ensuring proportional representation
for solid coalitions based on voters' ordinal preferences. It supports both `OrdinalProfile` and `OrdinalMultiProfile`.

Parameters
----------
instance : Instance
The budgeting instance, including all candidate projects and a total budget limit.

profile : AbstractOrdinalProfile
A profile of voters' preferences. Each voter submits a strict ranking over a subset of projects,
and is assigned a positive weight. Can be an `OrdinalProfile` or `OrdinalMultiProfile`.

verbose : bool, optional
If True, enables detailed debug logging (default is False).

rounding_precision : int, optional
The number of decimal places to round values for threshold comparisons and logging (default is 6).

Returns
-------
BudgetAllocation
An allocation containing the selected projects that respect the budget and satisfy the IPSC criterion.

Raises
------
ValueError
If the profile is not an instance of `AbstractOrdinalProfile`.
"""

if not isinstance(profile, AbstractOrdinalProfile):
raise ValueError("PB-EAR only supports ordinal profiles.")

if len(profile) == 0:
return BudgetAllocation()

budget = instance.budget_limit
project_cost = {p.name: p.cost for p in instance}
project_by_name = {p.name: p for p in instance}
all_projects = set(project_cost)

if verbose:
logger.info("=" * 30 + " NEW RUN: PB-EAR " + "=" * 30)

voters = [(ballot, profile.multiplicity(ballot)) for ballot in profile]
voter_weights = {ballot: weight for ballot, weight in voters}
initial_n = sum(voter_weights.values())

j = 1
selected_projects: set[Project] = set()
remaining_budget = budget

while True:
available_projects = [
p for p in all_projects - {proj.name for proj in selected_projects}
if project_cost[p] <= remaining_budget
]

if verbose:
logger.debug("Step j=%d — available_projects=%s, remaining_budget=%.2f", j, available_projects, remaining_budget)

if not available_projects:
break

approvals = defaultdict(set)
for ballot, _ in voters:
prefs = list(ballot)
if j <= len(prefs):
threshold = prefs[j - 1]
rank_threshold = prefs.index(threshold)
approvals[ballot] = set(prefs[:rank_threshold + 1])
else:
approvals[ballot] = set(prefs)

candidate_support = defaultdict(float)
for ballot, approved in approvals.items():
for p in approved:
if p not in {proj.name for proj in selected_projects}:
candidate_support[p] += voter_weights[ballot]

table = [
(
p,
f"{round(candidate_support[p], rounding_precision)}",
f"{round(project_cost[p], rounding_precision)}",
f"{round(frac((int(initial_n * project_cost[p])),(int(budget))), rounding_precision)}"
)
for p in available_projects
]
headers = ["Project", "Support", "Cost", "Threshold"]
if verbose:
logger.debug("\n%s", format_table(headers, table))

C_star = {
c for c in available_projects
if round(candidate_support[c], rounding_precision) >= round(
frac((int(initial_n * project_cost[c])),(int(budget))), rounding_precision
)
}


if not C_star:
max_rank = max(len(list(ballot)) for ballot, _ in voters)
if j > max_rank:
break
j += 1
continue

c_star = next(iter(C_star))
selected_projects.add(project_by_name[c_star])
remaining_budget -= project_cost[c_star]

if verbose:
logger.info("Selected candidate: %s | cost=%.2f | remaining_budget=%.2f", c_star, project_cost[c_star], remaining_budget)

N_prime = [ballot for ballot in approvals if c_star in approvals[ballot]]
total_weight_to_reduce = frac(
(int(initial_n * project_cost[c_star])),
(int(budget))
)


if N_prime:
sum_supporters = sum(voter_weights[b] for b in N_prime)
weight_fraction = (
frac((int(total_weight_to_reduce)), (int(sum_supporters)))
if sum_supporters > 0 else 0
)

for ballot in N_prime:
old_weight = voter_weights[ballot]
voter_weights[ballot] = voter_weights[ballot] * (1 - weight_fraction)
logger.debug("Reducing weight — old_weight=%.4f new_weight=%.4f", old_weight, voter_weights[ballot])

allocation = BudgetAllocation()
for project in sorted(selected_projects, key=lambda p: p.name):
allocation.append(project)

logger.info(
"Final selected projects: %s (total=%d)",
[p.name for p in sorted(selected_projects, key=lambda p: p.name)],
len(selected_projects)
)
return allocation
Loading