Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement exhaustive_stop argument for exhaustion rule #22

Merged
merged 2 commits into from
Apr 12, 2024
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
32 changes: 24 additions & 8 deletions pabutools/analysis/mesanalytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pabutools.utils import Numeric
from pabutools.election.instance import Instance, Project
from pabutools.election.profile import Profile
from pabutools.rules.budgetallocation import AllocationDetails
from pabutools.rules.budgetallocation import BudgetAllocation, AllocationDetails
from pabutools.rules.mes.mes_rule import method_of_equal_shares


Expand Down Expand Up @@ -95,11 +95,12 @@ def calculate_project_loss(
project_losses = []
voter_count = len(allocation_details.iterations[0].voters_budget)
voter_multiplicity = allocation_details.voter_multiplicity
iterations_count = len(allocation_details.iterations)
voter_spendings: dict[int, list[tuple[Project, Numeric]]] = {}
for idx in range(voter_count):
voter_spendings[idx] = []

for iteration in allocation_details.iterations:
for idx, iteration in enumerate(allocation_details.iterations):
project_losses.append(
_create_project_loss(
iteration.selected_project,
Expand All @@ -120,7 +121,10 @@ def calculate_project_loss(
)

for project_detail in iteration:
if project_detail.discarded:
if project_detail.discarded or (
idx == iterations_count - 1
and project_detail.project != iteration.selected_project
):
project_losses.append(
_create_project_loss(
project_detail.project,
Expand All @@ -137,6 +141,7 @@ def calculate_project_loss(
def calculate_effective_supports(
instance: Instance,
profile: Profile,
allocation: BudgetAllocation,
mes_params: dict | None = None,
final_budget: Numeric | None = None,
) -> dict[Project, int]:
Expand All @@ -150,8 +155,10 @@ def calculate_effective_supports(
----------
instance: :py:class:`~pabutools.election.instance.Instance`
The instance.
profile : :py:class:`~pabutools.election.profile.profile.AbstractProfile`
profile: :py:class:`~pabutools.election.profile.profile.AbstractProfile`
The profile.
allocation: :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation`
Resulting allocation of the above instance & profile.
mes_params: dict, optional
Dictionary of additional parameters that are passed as keyword arguments to the MES rule.
Defaults to None.
Expand All @@ -164,12 +171,14 @@ def calculate_effective_supports(
dict[:py:class:`~pabutools.election.instance.Project`, int]
Dictionary of pairs (:py:class:`~pabutools.election.instance.Project`, effective support).
"""
if mes_params is None:
mes_params = {}
effective_supports: dict[Project, int] = {}
if final_budget:
instance.budget_limit = final_budget
for project in instance:
effective_supports[project] = calculate_effective_support(
instance, profile, project, mes_params
instance, profile, project, project in allocation, mes_params
)

return effective_supports
Expand All @@ -179,11 +188,12 @@ def calculate_effective_support(
instance: Instance,
profile: Profile,
project: Project,
was_picked: bool,
mes_params: dict | None = None,
) -> int:
"""
Calculates the effective support of a given project in a given instance, profile and mes election.
Effective support for a project is an analytical metric which allows to measure the ratio of
Effective support for a project is an analytical metric which allows to measure the ratio of
initial budget received to minimal budget required to win. Effective support is represented in percentages.

Parameters
Expand All @@ -194,6 +204,8 @@ def calculate_effective_support(
The profile.
project: :py:class:`~pabutools.election.instance.Project`
Project for which effective support is calculated. Must be a part of the instance.
was_picked: bool
Whether the considerd project was picked as a winner in the allocation.
mes_params: dict, optional
Dictionary of additional parameters that are passed as keyword arguments to the MES rule.
Defaults to `{}`.
Expand All @@ -210,9 +222,13 @@ def calculate_effective_support(
mes_params["analytics"] = True
mes_params["skipped_project"] = project
mes_params["resoluteness"] = True
return method_of_equal_shares(
details = method_of_equal_shares(
instance, profile, **mes_params
).details.skipped_project_eff_support
).details
effective_support = details.skipped_project_eff_support
if was_picked:
effective_support = max(effective_support, 100)
return effective_support


def _create_project_loss(
Expand Down
8 changes: 6 additions & 2 deletions pabutools/rules/exhaustion.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def exhaustion_by_budget_increase(
rule_params: dict | None = None,
initial_budget_allocation: Iterable[Project] | None = None,
resoluteness: bool = True,
exhaustive_stop: bool = True,
budget_step: Numeric | None = None,
budget_bound: Numeric | None = None,
) -> BudgetAllocation | list[BudgetAllocation]:
Expand All @@ -127,6 +128,9 @@ def exhaustion_by_budget_increase(
resoluteness : bool, optional
Set to `False` to obtain an irresolute outcome, where all tied budget allocations are returned.
Defaults to True.
exhaustive_stop: bool, optional
Set to `False` to disable the exhaustive allocation stop condition, leaving only non-feasibility as
th stop condition of this rule. Defaults to True.
budget_step: Numeric
The step at which the budget is increased. Defaults to 1% of the budget limit.
budget_bound: Numeric
Expand Down Expand Up @@ -161,14 +165,14 @@ def exhaustion_by_budget_increase(
if resoluteness:
if not instance.is_feasible(outcome):
return previous_outcome
if instance.is_exhaustive(outcome):
if exhaustive_stop and instance.is_exhaustive(outcome):
return outcome
current_instance.budget_limit += budget_step
previous_outcome = outcome
else:
if any(not instance.is_feasible(o) for o in outcome):
return previous_outcome
if any(instance.is_exhaustive(o) for o in outcome):
if exhaustive_stop and any(instance.is_exhaustive(o) for o in outcome):
return outcome
current_instance.budget_limit += budget_step
previous_outcome = outcome
Expand Down
17 changes: 10 additions & 7 deletions tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ def test_project_loss(self):
{},
]

assert len(project_losses) == len(projects)
for idx, project_loss in enumerate(project_losses):
assert project_loss.name == projects[idx].name
assert project_loss.supporters_budget == expected_budgets[idx]
Expand All @@ -321,12 +322,12 @@ def test_project_loss(self):

@parameterized.expand(
[
([1, 1, 2, 1, 2], [200, 150, 37, 75, 50]),
([5, 1, 2, 1, 2], [60, 200, 50, 100, 50]),
([5, 5, 5, 5, 5], [80, 40, 30, 20, 20])
([1, 1, 2, 1, 2], [0, 1], [200, 150, 37, 75, 50]),
([5, 1, 2, 1, 2], [1, 3], [60, 200, 50, 100, 50]),
([5, 5, 5, 5, 5], [], [80, 40, 30, 20, 20]),
]
)
def test_effective_support(self, costs, expected_effective_support):
def test_effective_support(self, costs, allocation, expected_effective_support):
projects = [Project(chr(ord("a") + idx), costs[idx]) for idx in range(0, 5)]
instance = Instance(projects, budget_limit=2)
profile = ApprovalProfile(
Expand All @@ -343,12 +344,14 @@ def test_effective_support(self, costs, expected_effective_support):
ApprovalBallot({projects[4]}),
]
)

result = calculate_effective_supports(instance, profile, {"sat_class": Cost_Sat}, 5)
budget_allocation = BudgetAllocation(allocation)

result = calculate_effective_supports(
instance, profile, budget_allocation, {"sat_class": Cost_Sat}, 5
)
assert len(result) == len(projects)
sorted_projects = sorted(list(result), key=lambda proj: proj.name)

for idx, project in enumerate(sorted_projects):
assert project.name == chr(ord("a") + idx)
assert result[project] == expected_effective_support[idx]

6 changes: 5 additions & 1 deletion tests/test_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,8 @@ def test_mes_approval(self):
with self.assertRaises(ValueError):
method_of_equal_shares(Instance(), ApprovalProfile())

def test_iterated_exhaustion(self):
@parameterized.expand([(True,), (False,)])
def test_iterated_exhaustion(self, exhaustive_stop):
projects = [
Project("a", 1),
Project("b", 1),
Expand Down Expand Up @@ -540,6 +541,7 @@ def test_iterated_exhaustion(self):
method_of_equal_shares,
{"sat_class": Cost_Sat},
budget_step=frac(1, 24),
exhaustive_stop=exhaustive_stop
)
assert sorted(budget_allocation_mes_iterated) == [
projects[0],
Expand All @@ -555,6 +557,7 @@ def test_iterated_exhaustion(self):
{"sat_class": Cost_Sat},
budget_step=frac(1, 24),
initial_budget_allocation=[projects[6]],
exhaustive_stop=exhaustive_stop
)
assert sorted(budget_allocation_mes_iterated) == [
projects[0],
Expand All @@ -569,6 +572,7 @@ def test_iterated_exhaustion(self):
method_of_equal_shares,
{"sat_class": Cost_Sat},
budget_step=5,
exhaustive_stop=exhaustive_stop
)
assert budget_allocation_mes_iterated_big_steps == [projects[0]]

Expand Down
Loading