From 1f5a85d6889a9cfa3c392c4ebeabb47740c4a340 Mon Sep 17 00:00:00 2001 From: drknzz Date: Thu, 25 Apr 2024 01:28:19 +0200 Subject: [PATCH 1/4] Use mip>=1.16rc0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9177e97c..ab36781a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "numpy", - "mip", + "mip>=1.16rc0", "gmpy2>=2.1.5", "preflibtools", "natsort" From 1b2f362a5ed418f760ab3d193a10b55a1f94c89c Mon Sep 17 00:00:00 2001 From: drknzz Date: Thu, 25 Apr 2024 01:29:05 +0200 Subject: [PATCH 2/4] Add priceability module --- pabutools/analysis/__init__.py | 8 + pabutools/analysis/priceability.py | 401 +++++++++++++++++++++++++++++ pabutools/utils.py | 25 ++ tests/test_priceability.py | 181 +++++++++++++ 4 files changed, 615 insertions(+) create mode 100644 pabutools/analysis/priceability.py create mode 100644 tests/test_priceability.py diff --git a/pabutools/analysis/__init__.py b/pabutools/analysis/__init__.py index cf63c7bb..d41ac9f8 100644 --- a/pabutools/analysis/__init__.py +++ b/pabutools/analysis/__init__.py @@ -20,6 +20,11 @@ avg_total_score, median_total_score, ) +from pabutools.analysis.priceability import ( + validate_price_system, + priceable, + PriceableResult, +) from pabutools.analysis.mesanalytics import ( ProjectLoss, calculate_project_loss, @@ -52,6 +57,9 @@ "median_approval_score", "avg_total_score", "median_total_score", + "validate_price_system", + "priceable", + "PriceableResult", "avg_satisfaction", "gini_coefficient_of_satisfaction", "percent_non_empty_handed", diff --git a/pabutools/analysis/priceability.py b/pabutools/analysis/priceability.py new file mode 100644 index 00000000..00cd0250 --- /dev/null +++ b/pabutools/analysis/priceability.py @@ -0,0 +1,401 @@ +""" +Module with tools for analysis of the priceability / stable-priceability property of budget allocation. +""" + +from __future__ import annotations + +import collections +import time +from collections.abc import Collection +from typing import List, Dict + +from mip import Model, xsum, BINARY, OptimizationStatus, INT_MAX + +from pabutools.election import ( + Instance, + AbstractApprovalProfile, + Project, + total_cost, +) +from pabutools.utils import Numeric, round_cmp + +CHECK_ROUND_PRECISION = 2 +ROUND_PRECISION = 6 + + +def validate_price_system( + instance: Instance, + profile: AbstractApprovalProfile, + budget_allocation: Collection[Project], + voter_budget: Numeric, + payment_functions: List[Dict[Project, Numeric]], + stable: bool = False, + exhaustive: bool = True, + *, + verbose: bool = False, +) -> bool: + """ + Given a price system (`voter_budget`, `payment_functions`), + verifies whether `budget_allocation` is priceable / stable-priceable. + + :py:func:`~pabutools.utils.round_cmp`: is used across the implementation to ensure no rounding errors. + + Reference paper: https://www.cs.utoronto.ca/~nisarg/papers/priceability.pdf + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance. + profile : :py:class:`~pabutools.election.profile.profile.AbstractProfile` + The profile. + budget_allocation : Collection[:py:class:`~pabutools.election.instance.Project`] + The selected collection of projects. + voter_budget : Numeric + Voter initial endowment. + payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] + Collection of payment functions for each voter. + A payment function indicates the amounts paid for each project by a voter. + stable : bool, optional + Verify for stable-priceable allocation. + Defaults to `False`. + exhaustive : bool, optional + Verify for exhaustiveness of the allocation. + Defaults to `True`. + **verbose : bool, optional + Display additional information. + Defaults to `False`. + + Returns + ------- + bool + Boolean value specifying whether `budget_allocation` is priceable / stable-priceable. + + """ + C = instance + N = profile + W = budget_allocation + NW = [c for c in C if c not in W] + b = voter_budget + pf = payment_functions + total = total_cost(W) + spent = [sum(pf[idx][c] for c in C) for idx, _ in enumerate(N)] + leftover = [(b - spent[idx]) for idx, _ in enumerate(N)] + max_payment = [max((pf[idx][c] for c in C), default=0) for idx, _ in enumerate(N)] + + errors = collections.defaultdict(list) + + # equivalent of `instance.is_feasible(W)` + if total > instance.budget_limit: + errors["C0a"].append( + f"total price for allocation is equal {total} > {instance.budget_limit}" + ) + + if exhaustive: + # equivalent of `instance.is_exhaustive(W)` + for c in NW: + if total + c.cost <= instance.budget_limit: + errors["C0b"].append( + f"allocation is not exhaustive {total} + {c.cost} = {total + c.cost} <= {instance.budget_limit}" + ) + + for idx, i in enumerate(N): + for c in C: + if c not in i and pf[idx][c] != 0: + errors["C1"].append( + f"voter {idx} paid {pf[idx][c]} for unapproved project {c}" + ) + + for idx, _ in enumerate(N): + if round_cmp(spent[idx], b, CHECK_ROUND_PRECISION) > 0: + errors["C2"].append(f"payments of voter {idx} are equal {spent[idx]} > {b}") + + for c in W: + s = sum(pf[idx][c] for idx, _ in enumerate(N)) + if round_cmp(s, c.cost, CHECK_ROUND_PRECISION) != 0: + errors["C3"].append( + f"payments for selected project {c} are equal {s} != {c.cost}" + ) + + for c in NW: + s = sum(pf[idx][c] for idx, _ in enumerate(N)) + if round_cmp(s, 0, CHECK_ROUND_PRECISION) != 0: + errors["C4"].append( + f"payments for not selected project {c} are equal {s} != 0" + ) + + if not stable: + for c in NW: + s = sum(leftover[idx] for idx, i in enumerate(N) if c in i) + if round_cmp(s, c.cost, CHECK_ROUND_PRECISION) > 0: + errors["C5"].append( + f"voters' leftover money for not selected project {c} are equal {s} > {c.cost}" + ) + else: + for c in NW: + s = sum( + max(max_payment[idx], leftover[idx]) + for idx, i in enumerate(N) + if c in i + ) + if round_cmp(s, c.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}" + ) + + if verbose: + for condition, error in errors.items(): + print(f"({condition}) {error}") + + return not errors + + +class PriceableResult: + """ + Result of :py:func:`~pabutools.analysis.priceability.priceable`. + Contains information about the optimization status of ILP outcome. + If the status is valid (i.e. `OPTIMAL` / `FEASIBLE`), the class contains + the budget allocation, as well as the price system (`voter_budget`, `payment_functions`) + that satisfies the priceable / stable-priceable property. + + Parameters + ---------- + 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`. + voter_budget : float, optional + Voter initial endowment. + Defaults to `None`. + payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]], optional + List of payment functions for each voter. + A payment function indicates the amounts paid for each project by a voter. + Defaults to `None`. + + Attributes + ---------- + 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`. + voter_budget : bool or None + Voter initial endowment. + `None` if the optimization status is not `OPTIMAL` / `FEASIBLE`. + payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] or None + List of payment functions for each voter. + A payment function indicates the amounts paid for each project by a voter. + `None` if the optimization status is not `OPTIMAL` / `FEASIBLE`. + + """ + + def __init__( + self, + status: OptimizationStatus, + time_elapsed: float, + allocation: List[Project] | None = 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.voter_budget = voter_budget + self.payment_functions = payment_functions + + def validate(self) -> bool: + """ + Checks if the optimization status is `OPTIMAL` / `FEASIBLE`. + Returns + ------- + bool + Validity of optimization status. + + """ + return self.status in [OptimizationStatus.OPTIMAL, OptimizationStatus.FEASIBLE] + + +def priceable( + instance: Instance, + profile: AbstractApprovalProfile, + budget_allocation: Collection[Project] | None = None, + voter_budget: Numeric | None = None, + payment_functions: List[Dict[Project, Numeric]] | None = None, + stable: bool = False, + exhaustive: bool = True, + *, + max_seconds: int = 600, + verbose: bool = False, +) -> PriceableResult: + """ + Finds a priceable / stable-priceable budget allocation for approval profile + using Linear Programming via `mip` Python package. + + Reference paper: https://www.cs.utoronto.ca/~nisarg/papers/priceability.pdf + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance. + profile : :py:class:`~pabutools.election.profile.profile.AbstractProfile` + The profile. + budget_allocation : Collection[:py:class:`~pabutools.election.instance.Project`], optional + The selected collection of projects. + If specified, the allocation is hardcoded into the model. + Defaults to `None`. + voter_budget : Numeric + Voter initial endowment. + If specified, the voter budget is hardcoded into the model. + Defaults to `None`. + payment_functions : Collection[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] + Collection of payment functions for each voter. + If specified, the payment functions are hardcoded into the model. + Defaults to `None`. + stable : bool, optional + Search stable-priceable allocation. + Defaults to `False`. + exhaustive : bool, optional + Search exhaustive allocation. + Defaults to `True`. + **max_seconds : int, optional + Model's maximum runtime in seconds. + Defaults to 600. + **verbose : bool, optional + Display additional information. + Defaults to `False`. + + Returns + ------- + :py:class:`~pabutools.analysis.priceability.PriceableResult` + Dataclass containing priceable result details. + + """ + _start_time = time.time() + C = instance + N = profile + + mip_model = Model("stable-priceability" if stable else "priceability") + mip_model.verbose = verbose + + # voter budget + b = mip_model.add_var(name="voter_budget") + if voter_budget is not None: + 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] + if payment_functions is not None: + for idx, _ in enumerate(N): + for c in C: + mip_model += p_vars[idx][c] == payment_functions[idx][c] + + # winning allocation + x_vars = {c: mip_model.add_var(var_type=BINARY, name=f"x_{c.name}") for c in C} + if budget_allocation is not None: + for c in C: + if c in budget_allocation: + mip_model += x_vars[c] == 1 + else: + mip_model += x_vars[c] == 0 + + cost_total = xsum(x_vars[c] * c.cost for c in C) + + # (C0a) the winning allocation is feasible + mip_model += cost_total <= instance.budget_limit + + if exhaustive: + # (C0b) the winning allocation is exhaustive + for c in C: + mip_model += ( + cost_total + c.cost + x_vars[c] * INT_MAX >= instance.budget_limit + 1 + ) + elif budget_allocation is None: + # prevent empty allocation as a result + mip_model += b * profile.num_ballots() >= instance.budget_limit + + # (C1) voter can pay only for projects they approve of + for idx, i in enumerate(N): + for c in C: + if c not in i: + mip_model += p_vars[idx][c] == 0 + + # (C2) voter will not spend more than their initial budget + for idx, _ in enumerate(N): + mip_model += xsum(p_vars[idx][c] for c in C) <= b + + # (C3) the sum of the payments for selected project equals its cost + for c in C: + payments_total = xsum(p_vars[idx][c] for idx, _ in enumerate(N)) + + mip_model += payments_total <= c.cost + mip_model += c.cost + (x_vars[c] - 1) * INT_MAX <= payments_total + + # (C4) voters do not pay for not selected projects + for idx, _ in enumerate(N): + for c in C: + mip_model += 0 <= p_vars[idx][c] + mip_model += p_vars[idx][c] <= x_vars[c] * INT_MAX + + if not stable: + r_vars = [mip_model.add_var(name=f"r_{i.name}") for i in N] + for idx, _ in enumerate(N): + mip_model += r_vars[idx] == b - xsum(p_vars[idx][c] for c in C) + + # (C5) supporters of not selected project have no more money than its cost + for c in C: + mip_model += ( + xsum(r_vars[idx] for idx, i in enumerate(N) if c in i) + <= c.cost + x_vars[c] * INT_MAX + ) + else: + m_vars = [mip_model.add_var(name=f"m_{i.name}") for i in 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] * INT_MAX + ) + + 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 + # https://github.com/coin-or/python-mip/blob/1.15.0/mip/gurobi.py#L777 + # https://github.com/coin-or/python-mip/blob/1.16-pre/mip/gurobi.py#L778 + # + mip_model.solver.set_int_param("DualReductions", 0) + mip_model.reset() + 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) + + payment_functions = [collections.defaultdict(float) for _ in N] + for idx, _ in enumerate(N): + for c in C: + if p_vars[idx][c].x > 0: + payment_functions[idx][c] = p_vars[idx][c].x + + 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, + payment_functions=payment_functions, + ) diff --git a/pabutools/utils.py b/pabutools/utils.py index cd7c8b2f..52eeacd8 100644 --- a/pabutools/utils.py +++ b/pabutools/utils.py @@ -103,6 +103,31 @@ def gini_coefficient(values: Iterable[Numeric]) -> Numeric: return frac(num_values + 1 - frac(2 * total_cum_sum, sum(values)), num_values) +def round_cmp(a: Numeric, b: Numeric, precision: int = 6) -> int: + """ + Compares two numbers after rounding them to a specified precision. + + Parameters + ---------- + a : Numeric + The first number for comparison. + b : Numeric + The second number for comparison. + precision : int, optional + The number of decimal places to which the numbers should be rounded. + Defaults to 6. + + Returns + ------- + int + A negative number if the rounded value of 'a' is less than the rounded value of 'b', + 0 if they are approximately equal after rounding, + a positive number if the rounded value of 'a' is greater than the rounded value of 'b'. + + """ + return round(a, precision) - round(b, precision) + + class DocEnum(Enum): """ Enumeration with documentation of its members. Taken directly from diff --git a/tests/test_priceability.py b/tests/test_priceability.py new file mode 100644 index 00000000..e7f5e681 --- /dev/null +++ b/tests/test_priceability.py @@ -0,0 +1,181 @@ +""" +Module testing priceability / stable-priceability property. +""" + +# fmt: off + +from unittest import TestCase + +from pabutools.analysis.priceability import priceable, validate_price_system +from pabutools.election import Project, Instance, ApprovalProfile, ApprovalBallot + + +class TestPriceability(TestCase): + def test_priceable_approval(self): + # Example from https://arxiv.org/pdf/1911.11747.pdf page 2 + + # +----+----+----+ + # | c4 | c5 | c6 | + # +----+----+----+----+-----+-----+ + # | c3 | c9 | c12 | c15 | + # +--------------+----+-----+-----+ + # | c2 | c8 | c11 | c14 | + # +--------------+----+-----+-----+ + # | c1 | c7 | c10 | c13 | + # +===============================+ + # | v1 | v2 | v3 | v4 | v5 | v6 | + + p = [Project(str(i), cost=1) for i in range(16)] + instance = Instance(p[1:], budget_limit=12) + + v1 = ApprovalBallot({p[1], p[2], p[3], p[4]}) + v2 = ApprovalBallot({p[1], p[2], p[3], p[5]}) + v3 = ApprovalBallot({p[1], p[2], p[3], p[6]}) + v4 = ApprovalBallot({p[7], p[8], p[9]}) + v5 = ApprovalBallot({p[10], p[11], p[12]}) + v6 = ApprovalBallot({p[13], p[14], p[15]}) + profile = ApprovalProfile(init=[v1, v2, v3, v4, v5, v6]) + + allocation = p[1:4] + p[7:] + self.assertFalse(priceable(instance, profile, allocation).validate()) + + allocation = p[1:9] + p[10:12] + p[13:15] + self.assertTrue(priceable(instance, profile, allocation).validate()) + + res = priceable(instance, profile) + self.assertTrue(priceable(instance, profile, res.allocation, res.voter_budget, res.payment_functions).validate()) + self.assertTrue(priceable(instance, profile, res.allocation).validate()) + + self.assertTrue(validate_price_system(instance, profile, res.allocation, res.voter_budget, res.payment_functions)) + + def test_priceable_approval_2(self): + # Example from https://arxiv.org/pdf/1911.11747.pdf page 15 (k = 5) + + # +------------------------+ + # | c10 | + # +------------------------+ + # | c9 | + # +------------------------+ + # | c8 | + # +------------------------+ + # | c7 | + # +------------------------+ + # | c6 | + # +----+----+----+----+----+ + # | c1 | c2 | c3 | c4 | c5 | + # +========================+ + # | v1 | v2 | v3 | v4 | v5 | + + p = [Project(str(i), cost=1) for i in range(11)] + instance = Instance(p[1:], budget_limit=5) + + v1 = ApprovalBallot({p[1], p[6], p[7], p[8], p[9], p[10]}) + v2 = ApprovalBallot({p[2], p[6], p[7], p[8], p[9], p[10]}) + v3 = ApprovalBallot({p[3], p[6], p[7], p[8], p[9], p[10]}) + v4 = ApprovalBallot({p[4], p[6], p[7], p[8], p[9], p[10]}) + v5 = ApprovalBallot({p[5], p[6], p[7], p[8], p[9], p[10]}) + profile = ApprovalProfile(init=[v1, v2, v3, v4, v5]) + + allocation = p[1:3] + self.assertFalse(priceable(instance, profile, allocation).validate()) + + allocation = p[1:6] + self.assertTrue(priceable(instance, profile, allocation).validate()) + + allocation = p[6:] + self.assertTrue(priceable(instance, profile, allocation).validate()) + + res = priceable(instance, profile) + self.assertTrue(priceable(instance, profile, res.allocation, res.voter_budget, res.payment_functions).validate()) + self.assertTrue(priceable(instance, profile, res.allocation).validate()) + + self.assertTrue(validate_price_system(instance, profile, res.allocation, res.voter_budget, res.payment_functions)) + + def test_priceable_approval_3(self): + # Example from http://www.cs.utoronto.ca/~nisarg/papers/priceability.pdf page 13 + + # +--------------+--------------+--------------+ + # | c6 | c9 | c12 | + # +--------------+--------------+--------------+ + # | c5 | c8 | c11 | + # +--------------+--------------+--------------+ + # | c4 | c7 | c10 | + # +--------------+--------------+--------------+ + # | c3 | + # +--------------------------------------------+ + # | c2 | + # +--------------------------------------------+ + # | c1 | + # +============================================+ + # | v1 | v2 | v3 | v4 | v5 | v6 | v7 | v8 | v9 | + + p = [Project(str(i), cost=1) for i in range(13)] + instance = Instance(p[1:], budget_limit=9) + + v1 = ApprovalBallot({p[1], p[2], p[3], p[4], p[5], p[6]}) + v2 = ApprovalBallot({p[1], p[2], p[3], p[4], p[5], p[6]}) + v3 = ApprovalBallot({p[1], p[2], p[3], p[4], p[5], p[6]}) + + v4 = ApprovalBallot({p[1], p[2], p[3], p[7], p[8], p[9]}) + v5 = ApprovalBallot({p[1], p[2], p[3], p[7], p[8], p[9]}) + v6 = ApprovalBallot({p[1], p[2], p[3], p[7], p[8], p[9]}) + + v7 = ApprovalBallot({p[1], p[2], p[3], p[10], p[11], p[12]}) + v8 = ApprovalBallot({p[1], p[2], p[3], p[10], p[11], p[12]}) + v9 = ApprovalBallot({p[1], p[2], p[3], p[10], p[11], p[12]}) + profile = ApprovalProfile(init=[v1, v2, v3, v4, v5, v6, v7, v8, v9]) + + allocation = p[1:10] + self.assertTrue(priceable(instance, profile, allocation).validate()) + + allocation = p[1:6] + p[7:9] + p[10:12] + self.assertTrue(priceable(instance, profile, allocation).validate()) + + allocation = p[1:6] + p[7:9] + p[11:12] + self.assertFalse(priceable(instance, profile, allocation).validate()) + + res = priceable(instance, profile) + self.assertTrue(priceable(instance, profile, res.allocation, res.voter_budget, res.payment_functions).validate()) + self.assertTrue(priceable(instance, profile, res.allocation).validate()) + + self.assertTrue(validate_price_system(instance, profile, res.allocation, res.voter_budget, res.payment_functions)) + + def test_priceable_approval_4(self): + # Example from https://equalshares.net/explanation#example + + p = [ + Project("bike path", cost=700), + Project("outdoor gym", cost=400), + Project("new park", cost=250), + Project("new playground", cost=200), + Project("library for kids", cost=100), + ] + instance = Instance(p, budget_limit=1100) + + v1 = ApprovalBallot({p[0], p[1]}) + v2 = ApprovalBallot({p[0], p[1], p[2]}) + v3 = ApprovalBallot({p[0], p[1]}) + v4 = ApprovalBallot({p[0], p[1], p[2]}) + v5 = ApprovalBallot({p[0], p[1], p[2]}) + v6 = ApprovalBallot({p[0], p[1]}) + v7 = ApprovalBallot({p[2], p[3], p[4]}) + v8 = ApprovalBallot({p[3]}) + v9 = ApprovalBallot({p[3], p[4]}) + v10 = ApprovalBallot({p[2], p[3], p[4]}) + v11 = ApprovalBallot({p[0]}) + profile = ApprovalProfile(init=[v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11]) + + allocation = [p[0], p[1]] + self.assertFalse(priceable(instance, profile, allocation, stable=True).validate()) + + allocation = [p[0], p[2], p[4]] + self.assertFalse(priceable(instance, profile, allocation, stable=True).validate()) + + allocation = p[1:] + self.assertTrue(priceable(instance, profile, allocation, stable=True).validate()) + + res = priceable(instance, profile, stable=True) + self.assertTrue(priceable(instance, profile, res.allocation, res.voter_budget, res.payment_functions, stable=True).validate()) + self.assertTrue(priceable(instance, profile, res.allocation, stable=True).validate()) + + self.assertTrue(validate_price_system(instance, profile, res.allocation, res.voter_budget, res.payment_functions, stable=True)) From a053b11c41bce6b6cc537de99c3e79f74d135192 Mon Sep 17 00:00:00 2001 From: drknzz Date: Fri, 26 Apr 2024 01:34:25 +0200 Subject: [PATCH 3/4] Adjust typing --- pabutools/analysis/priceability.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pabutools/analysis/priceability.py b/pabutools/analysis/priceability.py index 00cd0250..86705624 100644 --- a/pabutools/analysis/priceability.py +++ b/pabutools/analysis/priceability.py @@ -7,7 +7,6 @@ import collections import time from collections.abc import Collection -from typing import List, Dict from mip import Model, xsum, BINARY, OptimizationStatus, INT_MAX @@ -28,7 +27,7 @@ def validate_price_system( profile: AbstractApprovalProfile, budget_allocation: Collection[Project], voter_budget: Numeric, - payment_functions: List[Dict[Project, Numeric]], + payment_functions: list[dict[Project, Numeric]], stable: bool = False, exhaustive: bool = True, *, @@ -52,7 +51,7 @@ def validate_price_system( The selected collection of projects. voter_budget : Numeric Voter initial endowment. - payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] + payment_functions : list[dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] Collection of payment functions for each voter. A payment function indicates the amounts paid for each project by a voter. stable : bool, optional @@ -169,7 +168,7 @@ class PriceableResult: voter_budget : float, optional Voter initial endowment. Defaults to `None`. - payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]], optional + payment_functions : list[dict[:py:class:`~pabutools.election.instance.Project`, Numeric]], optional List of payment functions for each voter. A payment function indicates the amounts paid for each project by a voter. Defaults to `None`. @@ -186,7 +185,7 @@ class PriceableResult: voter_budget : bool or None Voter initial endowment. `None` if the optimization status is not `OPTIMAL` / `FEASIBLE`. - payment_functions : List[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] or None + payment_functions : list[dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] or None List of payment functions for each voter. A payment function indicates the amounts paid for each project by a voter. `None` if the optimization status is not `OPTIMAL` / `FEASIBLE`. @@ -197,9 +196,9 @@ def __init__( self, status: OptimizationStatus, time_elapsed: float, - allocation: List[Project] | None = None, + allocation: list[Project] | None = None, voter_budget: float | None = None, - payment_functions: List[Dict[Project, float]] | None = None, + payment_functions: list[dict[Project, float]] | None = None, ) -> None: self.status = status self.time_elapsed = time_elapsed @@ -224,7 +223,7 @@ def priceable( profile: AbstractApprovalProfile, budget_allocation: Collection[Project] | None = None, voter_budget: Numeric | None = None, - payment_functions: List[Dict[Project, Numeric]] | None = None, + payment_functions: list[dict[Project, Numeric]] | None = None, stable: bool = False, exhaustive: bool = True, *, @@ -251,7 +250,7 @@ def priceable( Voter initial endowment. If specified, the voter budget is hardcoded into the model. Defaults to `None`. - payment_functions : Collection[Dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] + payment_functions : Collection[dict[:py:class:`~pabutools.election.instance.Project`, Numeric]] Collection of payment functions for each voter. If specified, the payment functions are hardcoded into the model. Defaults to `None`. From a1eae5bc63ddd2390afde528dcf0cd3efae62cc6 Mon Sep 17 00:00:00 2001 From: drknzz Date: Fri, 26 Apr 2024 01:35:56 +0200 Subject: [PATCH 4/4] Fix cbc solver inaccuracy --- pabutools/analysis/priceability.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pabutools/analysis/priceability.py b/pabutools/analysis/priceability.py index 86705624..f91e91ef 100644 --- a/pabutools/analysis/priceability.py +++ b/pabutools/analysis/priceability.py @@ -8,7 +8,7 @@ import time from collections.abc import Collection -from mip import Model, xsum, BINARY, OptimizationStatus, INT_MAX +from mip import Model, xsum, BINARY, OptimizationStatus from pabutools.election import ( Instance, @@ -276,9 +276,11 @@ def priceable( _start_time = time.time() C = instance N = profile + INF = instance.budget_limit * 10 mip_model = Model("stable-priceability" if stable else "priceability") mip_model.verbose = verbose + mip_model.integer_tol = 1e-8 # voter budget b = mip_model.add_var(name="voter_budget") @@ -310,7 +312,7 @@ def priceable( # (C0b) the winning allocation is exhaustive for c in C: mip_model += ( - cost_total + c.cost + x_vars[c] * INT_MAX >= instance.budget_limit + 1 + cost_total + c.cost + x_vars[c] * INF >= instance.budget_limit + 1 ) elif budget_allocation is None: # prevent empty allocation as a result @@ -331,13 +333,13 @@ def priceable( payments_total = xsum(p_vars[idx][c] for idx, _ in enumerate(N)) mip_model += payments_total <= c.cost - mip_model += c.cost + (x_vars[c] - 1) * INT_MAX <= payments_total + mip_model += c.cost + (x_vars[c] - 1) * INF <= payments_total # (C4) voters do not pay for not selected projects for idx, _ in enumerate(N): for c in C: mip_model += 0 <= p_vars[idx][c] - mip_model += p_vars[idx][c] <= x_vars[c] * INT_MAX + mip_model += p_vars[idx][c] <= x_vars[c] * INF if not stable: r_vars = [mip_model.add_var(name=f"r_{i.name}") for i in N] @@ -348,7 +350,7 @@ def priceable( for c in C: mip_model += ( xsum(r_vars[idx] for idx, i in enumerate(N) if c in i) - <= c.cost + x_vars[c] * INT_MAX + <= c.cost + x_vars[c] * INF ) else: m_vars = [mip_model.add_var(name=f"m_{i.name}") for i in N] @@ -361,7 +363,7 @@ def priceable( 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] * INT_MAX + <= c.cost + x_vars[c] * INF ) status = mip_model.optimize(max_seconds=max_seconds) @@ -388,7 +390,7 @@ def priceable( payment_functions = [collections.defaultdict(float) for _ in N] for idx, _ in enumerate(N): for c in C: - if p_vars[idx][c].x > 0: + if p_vars[idx][c].x > 1e-8: payment_functions[idx][c] = p_vars[idx][c].x return PriceableResult(