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
32 changes: 23 additions & 9 deletions pabutools/analysis/priceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Instance,
AbstractApprovalProfile,
Project,
total_cost,
total_cost, AbstractProfile, AbstractCardinalProfile,
)
from pabutools.utils import Numeric, round_cmp

Expand Down Expand Up @@ -241,7 +241,7 @@ def __repr__(self):

def priceable(
instance: Instance,
profile: AbstractApprovalProfile,
profile: AbstractProfile,
budget_allocation: Collection[Project] | None = None,
voter_budget: Numeric | None = None,
payment_functions: list[dict[Project, Numeric]] | None = None,
Expand Down Expand Up @@ -298,6 +298,11 @@ def priceable(
Dataclass containing priceable result details.

"""
if not isinstance(profile, AbstractApprovalProfile) and not isinstance(profile, AbstractCardinalProfile):
raise NotImplementedError(
f"Priceability and Stable-Priceability are not supported for {type(profile)}. "
)

C = instance
N = profile
INF = instance.budget_limit * 10
Expand Down Expand Up @@ -378,21 +383,30 @@ def priceable(
<= c.cost + x_vars[c] * INF
), f"C_supp_notselected_noafford_{c.name}"
else:
m_vars = [pulp.LpVariable(f"m_{idx}") for idx, _ in enumerate(N)]
m_vars = [
{c: pulp.LpVariable(f"m_{idx}_{c.name}", lowBound=0) for c in C}
for idx, i in enumerate(N)
]
for idx, i in enumerate(N):
for c1 in C:
if i.supports(c1):
for c2 in C:
if i.supports(c2):
model += (m_vars[idx][c1] * (1.0/i.utility(c1))) - (p_vars[idx][c2] * (1.0/i.utility(c2))) >= 0
model += m_vars[idx][c1] >= b - pulp.lpSum(p_vars[idx][c2] for c2 in C)
else:
model += m_vars[idx][c1] == 0
# Add the vars to the relaxation
if relaxation is not None:
for idx, _ in enumerate(N):
relaxation.variables[f"m_{idx}"] = m_vars[idx]
for idx, _ in enumerate(N):
for c in C:
model += m_vars[idx] >= p_vars[idx][c]
model += m_vars[idx] >= b - pulp.lpSum(p_vars[idx][c] for c in C)
for c in C:
relaxation.variables[f"m_{idx}_{c.name}"] = m_vars[idx][c]

# (S5) stability constraint
if relaxation is None:
for c in C:
model += (
pulp.lpSum(m_vars[idx] for idx, i in enumerate(N) if c in i)
pulp.lpSum(m_vars[idx][c] for idx, _ in enumerate(N)) \
<= c.cost + x_vars[c] * INF
)
else:
Expand Down
17 changes: 9 additions & 8 deletions pabutools/analysis/priceability_relaxation.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,14 @@ def add_objective(self, model: LpProblem) -> None:

def add_stability_constraint(self, model: LpProblem) -> None:
x_vars = {c: self.variables[f"x_{c.name}"] for c in self.C}
m_vars = [
self.variables[f"m_{idx}"] for idx, _ in enumerate(self.N)
]
m_vars = {
idx: {c: self.variables[f"m_{idx}_{c.name}"] for c in self.C}
for idx, _ in enumerate(self.N)
}
beta = self.variables["beta"]

for c in self.C:
model += lpSum(m_vars[idx] for idx, i in enumerate(self.N) if c in i) \
model += lpSum(m_vars[idx][c] for idx, i in enumerate(self.N)) \
<= c.cost * beta + x_vars[c] * self.INF

def get_beta(self) -> Real:
Expand All @@ -147,7 +148,7 @@ def add_objective(self, model: LpProblem) -> None:

def add_stability_constraint(self, model: LpProblem) -> None:
for c in self.C:
model += lpSum(self.variables[f"m_{idx}"] for idx, i in enumerate(self.N) if c in i) \
model += lpSum(self.variables[f"m_{idx}_{c.name}"] for idx, _ in enumerate(self.N)) \
<= c.cost + self.variables["beta"] + self.variables[f"x_{c.name}"] * self.INF

def get_beta(self) -> Real:
Expand All @@ -169,14 +170,14 @@ def add_beta(self, model: LpProblem) -> None:
# beta[c] is zero for selected
for c in self.C:
model += self.variables[f"beta_{c.name}"] <= (1 - self.variables[f"x_{c.name}"]) * self.instance.budget_limit
model += (self.variables[f"x_{c.name}"] - 1) * self.instance.budget_limit <= self.variables["beta"][c]
model += (self.variables[f"x_{c.name}"] - 1) * self.instance.budget_limit <= self.variables[f"beta_{c.name}"]

def add_objective(self, model: LpProblem) -> None:
model += -lpSum(self.variables[f"beta_{c.name}"] for c in self.C)

def add_stability_constraint(self, model: LpProblem) -> None:
for c in self.C:
model += lpSum(self.variables[f"m_{idx}"] for idx, i in enumerate(self.N) if c in i) \
model += lpSum(self.variables[f"m_{idx}_{c.name}"] for idx, _ in enumerate(self.N)) \
<= c.cost + self.variables[f"beta_{c.name}"] + self.variables[f"x_{c.name}"] * self.INF

def get_beta(self) -> dict:
Expand Down Expand Up @@ -217,7 +218,7 @@ def add_objective(self, model: LpProblem) -> None:

def add_stability_constraint(self, model: LpProblem) -> None:
for c in self.C:
model += lpSum(self.variables[f"m_{idx}"] for idx, i in enumerate(self.N) if c in i) \
model += lpSum(self.variables[f"m_{idx}_{c.name}"] for idx, _ in enumerate(self.N)) \
<= c.cost + self.variables["beta"] + self.variables[f"beta_{c.name}"] + self.variables[f"x_{c.name}"] * self.INF

def get_beta(self) -> dict:
Expand Down
6 changes: 6 additions & 0 deletions pabutools/election/ballot/approvalballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ def __init__(
Ballot.__init__(self, name, meta)
AbstractApprovalBallot.__init__(self)

def supports(self, c):
return self.utility(c) > 0

def utility(self, c):
return 1 if c in self else 0

def frozen(self) -> FrozenApprovalBallot:
"""
Returns the frozen approval ballot (that is hashable) corresponding to the ballot.
Expand Down
6 changes: 6 additions & 0 deletions pabutools/election/ballot/ballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def __init__(self, name: str = "", meta: dict | None = None):
self.meta = meta
self.name = name

def supports(self, c):
raise NotImplementedError("This ballot does not support the notion of 'supporting' projects.")

def utility(self, c):
raise NotImplementedError("This ballot does not support the notion of 'utility' for projects.")


class FrozenBallot(AbstractBallot, ABC):
"""
Expand Down
22 changes: 14 additions & 8 deletions pabutools/election/ballot/cardinalballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ class FrozenCardinalBallot(
"""

def __init__(
self,
init: dict[Project, Numeric] = (),
name: str | None = None,
meta: dict | None = None,
self,
init: dict[Project, Numeric] = (),
name: str | None = None,
meta: dict | None = None,
):
dict.__init__(self, init)
if name is None:
Expand Down Expand Up @@ -108,10 +108,10 @@ class CardinalBallot(dict[Project, Numeric], Ballot, AbstractCardinalBallot):
"""

def __init__(
self,
init: dict[Project, Numeric] | None = None,
name: str | None = None,
meta: dict | None = None,
self,
init: dict[Project, Numeric] | None = None,
name: str | None = None,
meta: dict | None = None,
):
if init is None:
init = dict()
Expand Down Expand Up @@ -156,6 +156,12 @@ def frozen(self) -> FrozenCardinalBallot:
"""
return FrozenCardinalBallot(self)

def supports(self, c):
return self.utility(c) > 0

def utility(self, c):
return self[c] if c in self else 0

# This allows dict method returning copies of a dict to work
@classmethod
def _wrap_methods(cls, names):
Expand Down
119 changes: 111 additions & 8 deletions tests/test_priceability.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@

from unittest import TestCase

from pabutools.election import Project, Instance, ApprovalProfile, ApprovalBallot
from pabutools.analysis.justifiedrepresentation import is_in_core

from pabutools.election import Project, Instance, ApprovalProfile, ApprovalBallot, CardinalProfile, CardinalBallot, \
Additive_Cardinal_Sat, OrdinalProfile
from pabutools.analysis.priceability import priceable, validate_price_system


def approval_profile_to_cardinal_profile(profile: ApprovalProfile) -> CardinalProfile:
def approval_ballot_to_cardinal_ballot(ballot: ApprovalBallot) -> CardinalBallot:
return CardinalBallot({c: 1 for c in ballot})

voters = [approval_ballot_to_cardinal_ballot(ballot) for ballot in profile]
return CardinalProfile(init=voters)


class TestPriceability(TestCase):
def test_priceable_approval(self):
Expand Down Expand Up @@ -42,10 +52,12 @@ def test_priceable_approval(self):
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, 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))
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)
Expand Down Expand Up @@ -85,10 +97,12 @@ def test_priceable_approval_2(self):
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, 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))
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
Expand Down Expand Up @@ -134,10 +148,12 @@ def test_priceable_approval_3(self):
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, 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))
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
Expand Down Expand Up @@ -178,4 +194,91 @@ def test_priceable_approval_4(self):
# 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))
self.assertTrue(
validate_price_system(instance, profile, res.allocation, res.voter_budget, res.payment_functions,
stable=True))

def test_stable_priceable_cardinal_reduces_to_approval_like_test_4(self):
# If cardinal profile contains only binary utilities, the implementation should give the same exact solutions.
# The election example is the same as in test_priceable_approval_4
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]})
approval_profile = ApprovalProfile(init=[v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11])
profile = approval_profile_to_cardinal_profile(approval_profile)

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)
# This is also not true due to pulp rounding error
# 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))

def test_stable_priceable_additive(self):
# Example from Master's Thesis "Stable Priceability for Additive Utilities" section 2.4
p = [
Project("p1", cost=1),
Project("p2", cost=1),
Project("p3", cost=1),
Project("p4", cost=1)
]

instance = Instance(p, budget_limit=2)

profile = CardinalProfile(
[
CardinalBallot({p[0]: 2, p[1]: 5, p[2]: 1, p[3]: 3}),
CardinalBallot({p[2]: 1, p[3]: 4}),
CardinalBallot({p[0]: 1, p[2]: 2}),
CardinalBallot({p[0]: 3, p[1]: 4, p[3]: 2})
]
)

res_any = priceable(instance=instance, profile=profile, stable=True)
self.assertTrue(
priceable(instance, profile, res_any.allocation, res_any.voter_budget,
res_any.payment_functions).validate())

# Additionally, it's a counterexample to SP implying the core for additive utilities
self.assertFalse(is_in_core(instance=instance,
profile=profile,
sat_class=Additive_Cardinal_Sat,
budget_allocation=res_any.allocation))

def test_stable_priceable_ordinal(self):
p = []

instance = Instance(p, budget_limit=1)

profile = OrdinalProfile()

self.assertRaises(NotImplementedError, priceable, instance=instance, profile=profile, stable=True)