Skip to content

Commit

Permalink
Merge pull request #27 from drknzz/stable-priceability-relaxation
Browse files Browse the repository at this point in the history
Add Stable-Priceability condition relaxation methods
  • Loading branch information
Simon-Rey authored May 3, 2024
2 parents ce555cb + 741026b commit 21eec05
Show file tree
Hide file tree
Showing 2 changed files with 344 additions and 26 deletions.
77 changes: 51 additions & 26 deletions pabutools/analysis/priceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from __future__ import annotations

import collections
import time
from collections.abc import Collection

from mip import Model, xsum, BINARY, OptimizationStatus

from pabutools.analysis.priceability_relaxation import Relaxation
from pabutools.election import (
Instance,
AbstractApprovalProfile,
Expand All @@ -30,6 +30,7 @@ def validate_price_system(
payment_functions: list[dict[Project, Numeric]],
stable: bool = False,
exhaustive: bool = True,
relaxation: Relaxation | None = None,
*,
verbose: bool = False,
) -> bool:
Expand Down Expand Up @@ -60,6 +61,9 @@ def validate_price_system(
exhaustive : bool, optional
Verify for exhaustiveness of the allocation.
Defaults to `True`.
relaxation : :py:class:`~pabutools.analysis.priceability_relaxation.Relaxation`, optional
Relaxation method to the stable-priceability condition.
Defaults to `None`.
**verbose : bool, optional
Display additional information.
Defaults to `False`.
Expand Down Expand Up @@ -136,9 +140,11 @@ def validate_price_system(
for idx, i in enumerate(N)
if c in i
)
if round_cmp(s, c.cost, CHECK_ROUND_PRECISION) > 0:

cost = c.cost if relaxation is None else relaxation.get_relaxed_cost(c)
if round_cmp(s, cost, CHECK_ROUND_PRECISION) > 0:
errors["S5"].append(
f"voters' leftover money (or the most they've spent for a project) for not selected project {c} are equal {s} > {c.cost}"
f"voters' leftover money (or the most they've spent for a project) for not selected project {c} are equal {s} > {cost}"
)

if verbose:
Expand All @@ -160,8 +166,6 @@ class PriceableResult:
----------
status : OptimizationStatus
Optimization status of the ILP outcome.
time_elapsed : float
Time taken to prepare and run the model.
allocation : Collection[:py:class:`~pabutools.election.instance.Project`], optional
The selected collection of projects.
Defaults to `None`.
Expand All @@ -177,8 +181,6 @@ class PriceableResult:
----------
status : OptimizationStatus
Optimization status of the ILP outcome.
time_elapsed : float
Time taken to prepare and run the model.
allocation : Collection[:py:class:`~pabutools.election.instance.Project`] or None
The selected collection of projects.
`None` if the optimization status is not `OPTIMAL` / `FEASIBLE`.
Expand All @@ -195,18 +197,18 @@ class PriceableResult:
def __init__(
self,
status: OptimizationStatus,
time_elapsed: float,
allocation: list[Project] | None = None,
relaxation_beta: float | dict = None,
voter_budget: float | None = None,
payment_functions: list[dict[Project, float]] | None = None,
) -> None:
self.status = status
self.time_elapsed = time_elapsed
self.allocation = allocation
self.relaxation_beta = relaxation_beta
self.voter_budget = voter_budget
self.payment_functions = payment_functions

def validate(self) -> bool:
def validate(self) -> bool | None:
"""
Checks if the optimization status is `OPTIMAL` / `FEASIBLE`.
Returns
Expand All @@ -215,6 +217,8 @@ def validate(self) -> bool:
Validity of optimization status.
"""
if self.status == OptimizationStatus.NO_SOLUTION_FOUND:
return None
return self.status in [OptimizationStatus.OPTIMAL, OptimizationStatus.FEASIBLE]


Expand All @@ -226,6 +230,7 @@ def priceable(
payment_functions: list[dict[Project, Numeric]] | None = None,
stable: bool = False,
exhaustive: bool = True,
relaxation: Relaxation | None = None,
*,
max_seconds: int = 600,
verbose: bool = False,
Expand Down Expand Up @@ -260,6 +265,9 @@ def priceable(
exhaustive : bool, optional
Search exhaustive allocation.
Defaults to `True`.
relaxation : :py:class:`~pabutools.analysis.priceability_relaxation.Relaxation`, optional
Relaxation method to the stable-priceability condition.
Defaults to `None`.
**max_seconds : int, optional
Model's maximum runtime in seconds.
Defaults to 600.
Expand All @@ -273,7 +281,6 @@ def priceable(
Dataclass containing priceable result details.
"""
_start_time = time.time()
C = instance
N = profile
INF = instance.budget_limit * 10
Expand All @@ -288,7 +295,10 @@ def priceable(
mip_model += b == voter_budget

# payment functions
p_vars = [{c: mip_model.add_var(name=f"p_{i.name}_{c.name}") for c in C} for i in N]
p_vars = [
{c: mip_model.add_var(name=f"p_{idx}_{c.name}") for c in C}
for idx, i in enumerate(N)
]
if payment_functions is not None:
for idx, _ in enumerate(N):
for c in C:
Expand Down Expand Up @@ -341,8 +351,11 @@ def priceable(
mip_model += 0 <= p_vars[idx][c]
mip_model += p_vars[idx][c] <= x_vars[c] * INF

if relaxation is not None:
relaxation.add_beta(mip_model)

if not stable:
r_vars = [mip_model.add_var(name=f"r_{i.name}") for i in N]
r_vars = [mip_model.add_var(name=f"r_{idx}") for idx, i in enumerate(N)]
for idx, _ in enumerate(N):
mip_model += r_vars[idx] == b - xsum(p_vars[idx][c] for c in C)

Expand All @@ -353,20 +366,29 @@ def priceable(
<= c.cost + x_vars[c] * INF
)
else:
m_vars = [mip_model.add_var(name=f"m_{i.name}") for i in N]
m_vars = [mip_model.add_var(name=f"m_{idx}") for idx, i in enumerate(N)]
for idx, _ in enumerate(N):
for c in C:
mip_model += m_vars[idx] >= p_vars[idx][c]
mip_model += m_vars[idx] >= b - xsum(p_vars[idx][c] for c in C)

# (S5) stability constraint
for c in C:
mip_model += (
xsum(m_vars[idx] for idx, i in enumerate(N) if c in i)
<= c.cost + x_vars[c] * INF
)
if relaxation is None:
for c in C:
mip_model += (
xsum(m_vars[idx] for idx, i in enumerate(N) if c in i)
<= c.cost + x_vars[c] * INF
)
else:
relaxation.add_stability_constraint(mip_model)

status = mip_model.optimize(max_seconds=max_seconds)
if relaxation is not None:
relaxation.add_objective(mip_model)

if relaxation is None:
status = mip_model.optimize(max_seconds=max_seconds, max_solutions=1)
else:
status = mip_model.optimize(max_seconds=max_seconds)

if status == OptimizationStatus.INF_OR_UNBD:
# https://support.gurobi.com/hc/en-us/articles/4402704428177-How-do-I-resolve-the-error-Model-is-infeasible-or-unbounded
Expand All @@ -375,17 +397,18 @@ def priceable(
#
mip_model.solver.set_int_param("DualReductions", 0)
mip_model.reset()
mip_model.optimize(max_seconds=max_seconds)
if relaxation is None:
mip_model.optimize(max_seconds=max_seconds, max_solutions=1)
else:
mip_model.optimize(max_seconds=max_seconds)
status = (
OptimizationStatus.INFEASIBLE
if mip_model.solver.get_int_attr("status") == 3
else OptimizationStatus.UNBOUNDED
)

_elapsed_time = time.time() - _start_time

if status in [OptimizationStatus.INFEASIBLE, OptimizationStatus.UNBOUNDED]:
return PriceableResult(status=status, time_elapsed=_elapsed_time)
if status not in [OptimizationStatus.OPTIMAL, OptimizationStatus.FEASIBLE]:
return PriceableResult(status=status)

payment_functions = [collections.defaultdict(float) for _ in N]
for idx, _ in enumerate(N):
Expand All @@ -395,8 +418,10 @@ def priceable(

return PriceableResult(
status=status,
time_elapsed=_elapsed_time,
allocation=list(sorted([c for c in C if x_vars[c].x >= 0.99])),
voter_budget=b.x,
relaxation_beta=relaxation.get_beta(mip_model)
if relaxation is not None
else None,
payment_functions=payment_functions,
)
Loading

0 comments on commit 21eec05

Please sign in to comment.