Skip to content

Commit aff1458

Browse files
committed
Generalising ILP schemes
1 parent 75500e9 commit aff1458

8 files changed

Lines changed: 323 additions & 300 deletions

File tree

tests/test_rules/test_chamberlin_courant.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,25 @@ def test_cc_on_trivial_instances(self):
3030
self.assertEqual(len(res), 0)
3131

3232
def test_cc_on_specific_instances(self):
33-
a = Alternative("a")
34-
b = Alternative("b")
35-
c = Alternative("c")
36-
d = Alternative("d")
37-
e = Alternative("e")
38-
alternatives = [a, b, c, d, e]
39-
ballots = [
40-
TrichotomousBallot(approved=[a, b], disapproved=[c]),
41-
TrichotomousBallot(approved=[c]),
42-
]
43-
profile = TrichotomousProfile(ballots, alternatives=alternatives)
44-
res = chamberlin_courant(profile, 3)
45-
self.assertEqual(len(res), 3)
46-
47-
profile.add_ballot(TrichotomousBallot(approved=[a], disapproved=[c]))
48-
res = chamberlin_courant(profile, 2)
49-
self.assertIn(a, res)
50-
self.assertNotIn(c, res)
33+
for cc_func in [chamberlin_courant, chamberlin_courant_brute_force]:
34+
a = Alternative("a")
35+
b = Alternative("b")
36+
c = Alternative("c")
37+
d = Alternative("d")
38+
e = Alternative("e")
39+
alternatives = [a, b, c, d, e]
40+
ballots = [
41+
TrichotomousBallot(approved=[a, b], disapproved=[c]),
42+
TrichotomousBallot(approved=[c]),
43+
]
44+
profile = TrichotomousProfile(ballots, alternatives=alternatives)
45+
res = cc_func(profile, 3)
46+
self.assertEqual(len(res), 3)
47+
48+
profile.add_ballot(TrichotomousBallot(approved=[a], disapproved=[c]))
49+
res = cc_func(profile, 2)
50+
self.assertIn(a, res)
51+
self.assertNotIn(c, res)
5152

5253
def test_with_brute_force(self):
5354
for _ in range(50):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import random
2+
from unittest import TestCase
3+
4+
from tests.random_instances import get_random_profile
5+
from trivoting.election import TrichotomousProfile, Selection, Alternative, TrichotomousBallot
6+
from trivoting.rules import max_net_support
7+
from trivoting.rules.chamberlin_courant import chamberlin_courant, chamberlin_courant_brute_force
8+
from trivoting.rules.max_net_support import max_net_support_ilp
9+
10+
11+
class TestMaxNetSupport(TestCase):
12+
13+
def test_max_net_support_on_random_instance(self):
14+
for _ in range(50):
15+
profile = get_random_profile(20, 50)
16+
max_size = random.randint(1, len(profile.alternatives))
17+
res = max_net_support(profile, max_size, resoluteness=True)
18+
self.assertLessEqual(len(res), max_size, f"Failure with max_net_support on: {profile}, k={max_size}")
19+
20+
def test_max_net_support_on_trivial_instances(self):
21+
# Empty profile
22+
profile = TrichotomousProfile()
23+
self.assertEqual(max_net_support(profile, 0), Selection())
24+
25+
# Only disapproved
26+
alternatives = [Alternative(str(i)) for i in range(10)]
27+
negative_ballots = [
28+
TrichotomousBallot(disapproved=alternatives[:6]) for _ in range(10)
29+
]
30+
profile = TrichotomousProfile(negative_ballots, alternatives=alternatives)
31+
res = max_net_support(profile, len(alternatives))
32+
self.assertEqual(len(res), 0)
33+
34+
def test_max_net_support_with_ilp(self):
35+
for _ in range(50):
36+
for m in range(2, 7):
37+
profile = get_random_profile(m, 50)
38+
max_size = random.randint(1, len(profile.alternatives))
39+
res1 = max_net_support(profile, max_size, resoluteness=True)
40+
support1 = profile.num_covered_ballots(res1)
41+
res2 = max_net_support_ilp(profile, max_size, resoluteness=True)
42+
support2 = profile.num_covered_ballots(res1)
43+
self.assertEqual(support1, support2, f"Failure max_net_support comparing to brute-force: {profile}, k={max_size}, ilp={res1} (s={support1}), ordering={res2} (s={support2})")

tests/test_rules/test_on_abcvoting_instances.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
MaxSatisfactionILPBuilder,
1717
)
1818
from trivoting.rules.chamberlin_courant import chamberlin_courant
19-
from trivoting.rules.max_satisfaction import max_satisfaction_ilp, max_satisfaction
19+
from trivoting.rules.max_net_support import max_net_support, max_net_support_ilp
2020
from trivoting.rules.thiele import (
2121
thiele_method,
2222
PAVILPKraiczy2025,
@@ -115,8 +115,8 @@ def exhaustivee_cc(
115115
partial(sequential_thiele, thiele_score_class=ApprovalOnlyScore),
116116
partial(sequential_thiele, thiele_score_class=SatisfactionScore),
117117
partial(thiele_method, ilp_builder_class=MaxSatisfactionILPBuilder),
118-
max_satisfaction_ilp,
119-
max_satisfaction,
118+
max_net_support_ilp,
119+
max_net_support,
120120
],
121121
"cc": exhaustivee_cc,
122122
# ABCvoting only has MES with completion...

trivoting/rules/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
DisapprovalLinearTax,
1818
)
1919
from trivoting.rules.phragmen import sequential_phragmen
20+
from trivoting.rules.chamberlin_courant import chamberlin_courant
21+
from trivoting.rules.max_net_support import max_net_support
2022

2123
__all__ = [
2224
"thiele_method",
@@ -34,4 +36,6 @@
3436
"TaxKraiczy2025",
3537
"DisapprovalLinearTax",
3638
"sequential_phragmen",
39+
"chamberlin_courant",
40+
"max_net_support",
3741
]

trivoting/rules/chamberlin_courant.py

Lines changed: 33 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
LpBinary,
1010
LpVariable,
1111
LpInteger,
12-
HiGHS,
12+
HiGHS, LpAffineExpression,
1313
)
1414

1515
from trivoting.election import AbstractTrichotomousProfile, Selection
16+
from trivoting.rules.ilp_schemes import ILPBUilder, ilp_optimiser_rule
1617
from trivoting.utils import generate_subsets
1718

1819

@@ -38,6 +39,35 @@ def chamberlin_courant_brute_force(
3839
return arg_max_coverage[0]
3940
return arg_max_coverage
4041

42+
class ChamberlinCourantILPBuilder(ILPBUilder):
43+
model_name = "ChamberlinCourant"
44+
45+
def init_vars(self) -> None:
46+
super(ChamberlinCourantILPBuilder, self).init_vars()
47+
48+
self.vars["sat_var"] = dict()
49+
self.vars["dissat_var"] = dict()
50+
self.vars["cc_var"] = dict()
51+
52+
for i, ballot in enumerate(self.profile):
53+
sat_var = LpVariable(f"sat_{i}", lowBound=-self.max_size_selection, upBound=self.max_size_selection, cat=LpInteger)
54+
dissat_var = LpVariable(f"dissat_{i}", lowBound=-self.max_size_selection, upBound=self.max_size_selection, cat=LpInteger)
55+
cc_var = LpVariable(f"cc_{i}", cat=LpBinary)
56+
57+
self.model += sat_var == lpSum(self.vars["selection"][alt] for alt in ballot.approved)
58+
self.model += dissat_var == lpSum(self.vars["selection"][alt] for alt in ballot.disapproved)
59+
60+
# Linearisation of z = 1 if x > y and z = 0 otherwise
61+
self.model += (sat_var - dissat_var >= 1 - (1 - cc_var) * self.max_size_selection)
62+
self.model += (sat_var - dissat_var <= cc_var * self.max_size_selection)
63+
64+
self.vars["sat_var"][i] = sat_var
65+
self.vars["dissat_var"][i] = dissat_var
66+
self.vars["cc_var"][i] = cc_var
67+
68+
def objective(self) -> LpAffineExpression:
69+
return lpSum(self.vars["cc_var"].values())
70+
4171

4272
def chamberlin_courant(
4373
profile: AbstractTrichotomousProfile,
@@ -75,114 +105,5 @@ def chamberlin_courant(
75105
The selection if resolute (:code:`resoluteness == True`), or a list of selections
76106
if irresolute (:code:`resoluteness == False`).
77107
"""
78-
model = LpProblem("ChamberlinCourant", LpMaximize)
79-
80-
selection_vars = {
81-
alt: LpVariable(f"y_{alt.name}", cat=LpBinary) for alt in profile.alternatives
82-
}
83-
84-
# Select no more than allowed
85-
model += lpSum(selection_vars.values()) <= max_size_selection
86-
87-
# Handle initial selection
88-
if initial_selection is not None:
89-
for alt in initial_selection.selected:
90-
model += selection_vars[alt] == 1
91-
if not initial_selection.implicit_reject:
92-
for alt in initial_selection.rejected:
93-
model += selection_vars[alt] == 0
94-
95-
# Voters satisfaction variables
96-
voter_details = []
97-
for i, ballot in enumerate(profile):
98-
voter = {
99-
"ballot": ballot,
100-
"multiplicity": profile.multiplicity(ballot),
101-
"sat_var": LpVariable(
102-
f"sat_{i}", lowBound=-max_size_selection, upBound=max_size_selection, cat=LpInteger
103-
),
104-
"dissat_var": LpVariable(
105-
f"disat_{i}", lowBound=-max_size_selection, upBound=max_size_selection, cat=LpInteger
106-
),
107-
"cc_var": LpVariable(f"cc_{i}", cat=LpBinary),
108-
}
109-
voter_details.append(voter)
110-
111-
# Constraint voter_details to ensure proper counting
112-
for voter in voter_details:
113-
model += voter["sat_var"] == lpSum(
114-
selection_vars[alt] for alt in voter["ballot"].approved
115-
)
116-
model += voter["dissat_var"] == lpSum(
117-
selection_vars[alt] for alt in voter["ballot"].disapproved
118-
)
119-
120-
# Linearisation of z = 1 if x > y and z = 0 otherwise
121-
model += (
122-
voter["sat_var"] - voter["dissat_var"]
123-
>= 1 - (1 - voter["cc_var"]) * max_size_selection
124-
)
125-
model += (
126-
voter["sat_var"] - voter["dissat_var"]
127-
<= voter["cc_var"] * max_size_selection
128-
)
129-
130-
# Objective: max PAV score
131-
model += lpSum(voter["cc_var"] for voter in voter_details)
132-
133-
status = model.solve(HiGHS(msg=verbose, timeLimit=max_seconds))
134-
135-
all_selections = []
136-
137-
if status == LpStatusOptimal:
138-
selection = Selection(implicit_reject=True)
139-
for alt, v in selection_vars.items():
140-
if value(v) >= 0.9:
141-
selection.add_selected(alt)
142-
all_selections.append(selection)
143-
else:
144-
raise ValueError(
145-
f"Solver did not find an optimal solution, status is {status}."
146-
)
147-
148-
if resoluteness:
149-
return all_selections[0]
150-
151-
# If irresolute, we solve again, banning the previous selections
152-
model += lpSum(voter["cc_var"] for voter in voter_details) == value(model.objective)
153-
154-
previous_selection = selection
155-
while True:
156-
# See http://yetanothermathprogrammingconsultant.blogspot.com/2011/10/integer-cuts.html
157-
model += (
158-
lpSum((1 - selection_vars[a]) for a in previous_selection.selected)
159-
+ lpSum(
160-
selection_vars[a] for a in selection_vars if a not in previous_selection
161-
)
162-
) >= 1
163-
164-
model += (
165-
lpSum(selection_vars[a] for a in previous_selection.selected)
166-
- lpSum(
167-
selection_vars[a] for a in selection_vars if a not in previous_selection
168-
)
169-
) <= len(previous_selection) - 1
170-
171-
status = model.solve(HiGHS(msg=verbose, timeLimit=max_seconds))
172-
173-
if status != LpStatusOptimal:
174-
break
175-
176-
previous_selection = Selection(
177-
[
178-
a
179-
for a in selection_vars
180-
if value(selection_vars[a]) is not None
181-
and value(selection_vars[a]) >= 0.9
182-
],
183-
implicit_reject=True,
184-
)
185-
if previous_selection not in all_selections:
186-
all_selections.append(previous_selection)
187-
188-
return all_selections
108+
ilp_builder = ChamberlinCourantILPBuilder(profile, max_size_selection, initial_selection, max_seconds=max_seconds, verbose=verbose)
109+
return ilp_optimiser_rule(ilp_builder, resoluteness=resoluteness)

0 commit comments

Comments
 (0)