sortition-algorithms 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sortition_algorithms/__init__.py +22 -0
- sortition_algorithms/__main__.py +229 -0
- sortition_algorithms/adapters.py +308 -0
- sortition_algorithms/committee_generation.py +1377 -0
- sortition_algorithms/core.py +591 -0
- sortition_algorithms/errors.py +26 -0
- sortition_algorithms/features.py +351 -0
- sortition_algorithms/find_sample.py +93 -0
- sortition_algorithms/people.py +175 -0
- sortition_algorithms/people_features.py +288 -0
- sortition_algorithms/settings.py +110 -0
- sortition_algorithms/utils.py +107 -0
- sortition_algorithms-0.9.0.dist-info/METADATA +191 -0
- sortition_algorithms-0.9.0.dist-info/RECORD +17 -0
- sortition_algorithms-0.9.0.dist-info/WHEEL +4 -0
- sortition_algorithms-0.9.0.dist-info/entry_points.txt +2 -0
- sortition_algorithms-0.9.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from collections.abc import Collection, Iterable
|
|
3
|
+
from math import log
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import cvxpy as cp
|
|
7
|
+
import mip
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import gurobipy as grb
|
|
12
|
+
|
|
13
|
+
GUROBI_AVAILABLE = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
GUROBI_AVAILABLE = False
|
|
16
|
+
grb = None
|
|
17
|
+
|
|
18
|
+
from sortition_algorithms import errors
|
|
19
|
+
from sortition_algorithms.features import FeatureCollection
|
|
20
|
+
from sortition_algorithms.people import People
|
|
21
|
+
from sortition_algorithms.settings import Settings
|
|
22
|
+
from sortition_algorithms.utils import print_ret, random_provider
|
|
23
|
+
|
|
24
|
+
# Tolerance for numerical comparisons
|
|
25
|
+
EPS = 0.0005
|
|
26
|
+
EPS2 = 0.00000001
|
|
27
|
+
EPS_NASH = 0.1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reduction_weight(features: FeatureCollection, feature_name: str, value_name: str) -> float:
|
|
31
|
+
"""Make the algorithm more reluctant to reduce lower quotas that are already low.
|
|
32
|
+
If the lower quota was 1, reducing it one more (to 0) is 3 times more salient than
|
|
33
|
+
increasing a quota by 1. This bonus tapers off quickly, reducing from 10 is only
|
|
34
|
+
1.2 times as salient as an increase."""
|
|
35
|
+
# Find the current min quota for this feature-value
|
|
36
|
+
for fname, vname, vcounts in features.feature_values_counts():
|
|
37
|
+
if fname == feature_name and vname == value_name:
|
|
38
|
+
old_quota = vcounts.min
|
|
39
|
+
if old_quota == 0:
|
|
40
|
+
return 0 # cannot be relaxed anyway
|
|
41
|
+
return 1 + 2 / old_quota
|
|
42
|
+
return 1 # fallback
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _create_relaxation_variables_and_bounds(
|
|
46
|
+
model: mip.model.Model,
|
|
47
|
+
features: FeatureCollection,
|
|
48
|
+
) -> tuple[dict[tuple[str, str], mip.entities.Var], dict[tuple[str, str], mip.entities.Var]]:
|
|
49
|
+
"""Create relaxation variables and add bounds constraints for quota relaxation.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
model: MIP model to add variables and constraints to
|
|
53
|
+
features: FeatureCollection with min/max quotas and flex bounds
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
tuple of (min_vars, max_vars) - dictionaries mapping feature-value pairs to relaxation variables
|
|
57
|
+
"""
|
|
58
|
+
# Get all feature-value pairs
|
|
59
|
+
feature_values = [(feature_name, value_name) for feature_name, value_name, _ in features.feature_values_counts()]
|
|
60
|
+
|
|
61
|
+
# Create relaxation variables for each feature-value pair
|
|
62
|
+
min_vars = {fv: model.add_var(var_type=mip.INTEGER, lb=0.0) for fv in feature_values}
|
|
63
|
+
max_vars = {fv: model.add_var(var_type=mip.INTEGER, lb=0.0) for fv in feature_values}
|
|
64
|
+
|
|
65
|
+
# Add constraints ensuring relaxations stay within min_flex/max_flex bounds
|
|
66
|
+
for feature_name, value_name, value_counts in features.feature_values_counts():
|
|
67
|
+
fv = (feature_name, value_name)
|
|
68
|
+
|
|
69
|
+
# Relaxed min cannot go below min_flex
|
|
70
|
+
model.add_constr(value_counts.min - min_vars[fv] >= value_counts.min_flex)
|
|
71
|
+
|
|
72
|
+
# Relaxed max cannot go above max_flex
|
|
73
|
+
model.add_constr(value_counts.max + max_vars[fv] <= value_counts.max_flex)
|
|
74
|
+
|
|
75
|
+
return min_vars, max_vars
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_committee_constraints_for_inclusion_set(
|
|
79
|
+
model: mip.model.Model,
|
|
80
|
+
inclusion_set: Iterable[str],
|
|
81
|
+
people: People,
|
|
82
|
+
number_people_wanted: int,
|
|
83
|
+
features: FeatureCollection,
|
|
84
|
+
min_vars: dict[tuple[str, str], mip.entities.Var],
|
|
85
|
+
max_vars: dict[tuple[str, str], mip.entities.Var],
|
|
86
|
+
settings: Settings,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Add constraints to ensure a valid committee exists that includes the specified agents.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
model: MIP model to add constraints to
|
|
92
|
+
inclusion_set: agents that must be included in this committee
|
|
93
|
+
people: People object with pool members
|
|
94
|
+
number_people_wanted: desired size of the panel
|
|
95
|
+
features: FeatureCollection with quotas
|
|
96
|
+
min_vars: relaxation variables for minimum quotas
|
|
97
|
+
max_vars: relaxation variables for maximum quotas
|
|
98
|
+
settings: Settings object containing household checking configuration
|
|
99
|
+
"""
|
|
100
|
+
# Binary variables for each person (selected/not selected)
|
|
101
|
+
agent_vars = {person_id: model.add_var(var_type=mip.BINARY) for person_id in people}
|
|
102
|
+
|
|
103
|
+
# Force inclusion of specified agents
|
|
104
|
+
for agent in inclusion_set:
|
|
105
|
+
model.add_constr(agent_vars[agent] == 1)
|
|
106
|
+
|
|
107
|
+
# Must select exactly the desired number of people
|
|
108
|
+
model.add_constr(mip.xsum(agent_vars.values()) == number_people_wanted)
|
|
109
|
+
|
|
110
|
+
# Respect relaxed quotas for each feature-value
|
|
111
|
+
for feature_name, value_name, value_counts in features.feature_values_counts():
|
|
112
|
+
fv = (feature_name, value_name)
|
|
113
|
+
|
|
114
|
+
# Count people with this feature-value who are selected
|
|
115
|
+
number_feature_value_agents = mip.xsum(
|
|
116
|
+
agent_vars[person_id]
|
|
117
|
+
for person_id, person_data in people.items()
|
|
118
|
+
if person_data[feature_name] == value_name
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Apply relaxed min/max constraints
|
|
122
|
+
model.add_constr(number_feature_value_agents >= value_counts.min - min_vars[fv])
|
|
123
|
+
model.add_constr(number_feature_value_agents <= value_counts.max + max_vars[fv])
|
|
124
|
+
|
|
125
|
+
# Household constraints: at most 1 person per household
|
|
126
|
+
if settings.check_same_address:
|
|
127
|
+
for housemates in people.households(settings.check_same_address_columns).values():
|
|
128
|
+
if len(housemates) > 1:
|
|
129
|
+
model.add_constr(mip.xsum(agent_vars[member_id] for member_id in housemates) <= 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _solve_relaxation_model_and_handle_errors(model: mip.model.Model) -> None:
|
|
133
|
+
"""Solve the relaxation model and handle any optimization errors.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
model: MIP model to solve
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
InfeasibleQuotasCantRelaxError: If quotas cannot be relaxed within flex bounds
|
|
140
|
+
SelectionError: If solver fails for other reasons
|
|
141
|
+
"""
|
|
142
|
+
status = model.optimize()
|
|
143
|
+
if status == mip.OptimizationStatus.INFEASIBLE:
|
|
144
|
+
msg = (
|
|
145
|
+
"No feasible committees found, even with relaxing the quotas. Most "
|
|
146
|
+
"likely, quotas would have to be relaxed beyond what the 'min_flex' and "
|
|
147
|
+
"'max_flex' columns allow."
|
|
148
|
+
)
|
|
149
|
+
raise errors.InfeasibleQuotasCantRelaxError(msg)
|
|
150
|
+
if status != mip.OptimizationStatus.OPTIMAL:
|
|
151
|
+
msg = (
|
|
152
|
+
f"No feasible committees found, solver returns code {status} (see "
|
|
153
|
+
f"https://docs.python-mip.com/en/latest/classes.html#optimizationstatus). Either the pool "
|
|
154
|
+
f"is very bad or something is wrong with the solver."
|
|
155
|
+
)
|
|
156
|
+
raise errors.SelectionError(msg)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _extract_relaxed_features_and_messages(
|
|
160
|
+
features: FeatureCollection,
|
|
161
|
+
min_vars: dict[tuple[str, str], mip.entities.Var],
|
|
162
|
+
max_vars: dict[tuple[str, str], mip.entities.Var],
|
|
163
|
+
) -> tuple[FeatureCollection, list[str]]:
|
|
164
|
+
"""Extract relaxed quotas from solved model and generate recommendation messages.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
features: Original FeatureCollection with quotas
|
|
168
|
+
min_vars: solved relaxation variables for minimum quotas
|
|
169
|
+
max_vars: solved relaxation variables for maximum quotas
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
tuple of (relaxed FeatureCollection, list of recommendation messages)
|
|
173
|
+
"""
|
|
174
|
+
# Create a copy of the features with relaxed quotas
|
|
175
|
+
relaxed_features = copy.deepcopy(features)
|
|
176
|
+
output_lines = []
|
|
177
|
+
|
|
178
|
+
for (
|
|
179
|
+
feature_name,
|
|
180
|
+
value_name,
|
|
181
|
+
value_counts,
|
|
182
|
+
) in relaxed_features.feature_values_counts():
|
|
183
|
+
fv = (feature_name, value_name)
|
|
184
|
+
min_relaxation = round(min_vars[fv].x)
|
|
185
|
+
max_relaxation = round(max_vars[fv].x)
|
|
186
|
+
|
|
187
|
+
original_min = value_counts.min
|
|
188
|
+
original_max = value_counts.max
|
|
189
|
+
|
|
190
|
+
new_min = original_min - min_relaxation
|
|
191
|
+
new_max = original_max + max_relaxation
|
|
192
|
+
|
|
193
|
+
# Update the values
|
|
194
|
+
value_counts.min = new_min
|
|
195
|
+
value_counts.max = new_max
|
|
196
|
+
|
|
197
|
+
# Generate output messages
|
|
198
|
+
if new_min < original_min:
|
|
199
|
+
output_lines.append(f"Recommend lowering lower quota of {feature_name}:{value_name} to {new_min}.")
|
|
200
|
+
if new_max > original_max:
|
|
201
|
+
output_lines.append(f"Recommend raising upper quota of {feature_name}:{value_name} to {new_max}.")
|
|
202
|
+
|
|
203
|
+
return relaxed_features, output_lines
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _relax_infeasible_quotas(
|
|
207
|
+
features: FeatureCollection,
|
|
208
|
+
people: People,
|
|
209
|
+
number_people_wanted: int,
|
|
210
|
+
settings: Settings,
|
|
211
|
+
ensure_inclusion: Collection[Iterable[str]] = ((),),
|
|
212
|
+
) -> tuple[FeatureCollection, list[str]]:
|
|
213
|
+
"""Assuming that the quotas are not satisfiable, suggest a minimal relaxation that would be.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
features: FeatureCollection with min/max quotas
|
|
217
|
+
people: People object with pool members
|
|
218
|
+
number_people_wanted: desired size of the panel
|
|
219
|
+
settings: Settings object containing check_same_address and check_same_address_columns
|
|
220
|
+
ensure_inclusion: allows to specify that some panels should contain specific sets of agents. for example,
|
|
221
|
+
passing `(("a",), ("b", "c"))` means that the quotas should be relaxed such that some valid panel contains
|
|
222
|
+
agent "a" and some valid panel contains both agents "b" and "c". the default of `((),)` just requires
|
|
223
|
+
a panel to exist, without further restrictions.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
tuple of (relaxed FeatureCollection, list of output messages)
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
InfeasibleQuotasCantRelaxError: If quotas cannot be relaxed within min_flex/max_flex bounds
|
|
230
|
+
SelectionError: If solver fails for other reasons
|
|
231
|
+
"""
|
|
232
|
+
model = mip.Model(sense=mip.MINIMIZE)
|
|
233
|
+
model.verbose = 0 # TODO: get debug level from settings
|
|
234
|
+
|
|
235
|
+
assert len(ensure_inclusion) > 0 # otherwise, the existence of a panel is not required
|
|
236
|
+
|
|
237
|
+
# Create relaxation variables and bounds constraints
|
|
238
|
+
min_vars, max_vars = _create_relaxation_variables_and_bounds(model, features)
|
|
239
|
+
|
|
240
|
+
# Get feature-value pairs for objective function
|
|
241
|
+
feature_values = [(feature_name, value_name) for feature_name, value_name, _ in features.feature_values_counts()]
|
|
242
|
+
|
|
243
|
+
# For each inclusion set, create constraints to ensure a valid committee exists
|
|
244
|
+
for inclusion_set in ensure_inclusion:
|
|
245
|
+
_add_committee_constraints_for_inclusion_set(
|
|
246
|
+
model,
|
|
247
|
+
inclusion_set,
|
|
248
|
+
people,
|
|
249
|
+
number_people_wanted,
|
|
250
|
+
features,
|
|
251
|
+
min_vars,
|
|
252
|
+
max_vars,
|
|
253
|
+
settings,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Objective: minimize weighted sum of relaxations
|
|
257
|
+
model.objective = mip.xsum(
|
|
258
|
+
[_reduction_weight(features, *fv) * min_vars[fv] for fv in feature_values]
|
|
259
|
+
+ [max_vars[fv] for fv in feature_values]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Solve the model and handle errors
|
|
263
|
+
_solve_relaxation_model_and_handle_errors(model)
|
|
264
|
+
|
|
265
|
+
# Extract results and generate messages
|
|
266
|
+
return _extract_relaxed_features_and_messages(features, min_vars, max_vars)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _setup_committee_generation(
|
|
270
|
+
features: FeatureCollection,
|
|
271
|
+
people: People,
|
|
272
|
+
number_people_wanted: int,
|
|
273
|
+
settings: Settings,
|
|
274
|
+
) -> tuple[mip.model.Model, dict[str, mip.entities.Var]]:
|
|
275
|
+
"""Set up the integer linear program for committee generation.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
features: FeatureCollection with min/max quotas
|
|
279
|
+
people: People object with pool members
|
|
280
|
+
number_people_wanted: desired size of the panel
|
|
281
|
+
settings: Settings object containing household checking configuration
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
tuple of (MIP model, dict mapping person_id to binary variables)
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
InfeasibleQuotasError: If quotas are infeasible, includes suggested relaxations
|
|
288
|
+
SelectionError: If solver fails for other reasons
|
|
289
|
+
"""
|
|
290
|
+
model = mip.Model(sense=mip.MAXIMIZE)
|
|
291
|
+
model.verbose = 0 # TODO: get debug level from settings
|
|
292
|
+
|
|
293
|
+
# Binary variable for each person (selected/not selected)
|
|
294
|
+
agent_vars = {person_id: model.add_var(var_type=mip.BINARY) for person_id in people}
|
|
295
|
+
|
|
296
|
+
# Must select exactly the desired number of people
|
|
297
|
+
model.add_constr(mip.xsum(agent_vars.values()) == number_people_wanted)
|
|
298
|
+
|
|
299
|
+
# Respect min/max quotas for each feature value
|
|
300
|
+
for feature_name, value_name, value_counts in features.feature_values_counts():
|
|
301
|
+
# Count people with this feature-value who are selected
|
|
302
|
+
number_feature_value_agents = mip.xsum(
|
|
303
|
+
agent_vars[person_id]
|
|
304
|
+
for person_id, person_data in people.items()
|
|
305
|
+
if person_data[feature_name] == value_name
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Add min/max constraints
|
|
309
|
+
model.add_constr(number_feature_value_agents >= value_counts.min)
|
|
310
|
+
model.add_constr(number_feature_value_agents <= value_counts.max)
|
|
311
|
+
|
|
312
|
+
# Household constraints: at most 1 person per household
|
|
313
|
+
if settings.check_same_address:
|
|
314
|
+
for housemates in people.households(settings.check_same_address_columns).values():
|
|
315
|
+
if len(housemates) > 1:
|
|
316
|
+
model.add_constr(mip.xsum(agent_vars[member_id] for member_id in housemates) <= 1)
|
|
317
|
+
|
|
318
|
+
# Test feasibility by optimizing once
|
|
319
|
+
status = model.optimize()
|
|
320
|
+
if status == mip.OptimizationStatus.INFEASIBLE:
|
|
321
|
+
relaxed_features, output_lines = _relax_infeasible_quotas(features, people, number_people_wanted, settings)
|
|
322
|
+
raise errors.InfeasibleQuotasError(relaxed_features, output_lines)
|
|
323
|
+
if status != mip.OptimizationStatus.OPTIMAL:
|
|
324
|
+
msg = (
|
|
325
|
+
f"No feasible committees found, solver returns code {status} (see "
|
|
326
|
+
"https://docs.python-mip.com/en/latest/classes.html#optimizationstatus)."
|
|
327
|
+
)
|
|
328
|
+
raise errors.SelectionError(msg)
|
|
329
|
+
|
|
330
|
+
return model, agent_vars
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ilp_results_to_committee(variables: dict[str, mip.entities.Var]) -> frozenset[str]:
|
|
334
|
+
"""Extract the selected committee from ILP solver variables.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
variables: dict mapping person_id to binary MIP variables
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
frozenset of person_ids who are selected (have variable value > 0.5)
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
ValueError: If variables don't have values (solver failed)
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
committee = frozenset(person_id for person_id in variables if variables[person_id].x > 0.5)
|
|
347
|
+
# unfortunately, MIP sometimes throws generic Exceptions rather than a subclass
|
|
348
|
+
except Exception as error:
|
|
349
|
+
msg = f"It seems like some variables do not have a value. Original exception: {error}."
|
|
350
|
+
raise ValueError(msg) from error
|
|
351
|
+
|
|
352
|
+
return committee
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def find_any_committee(
|
|
356
|
+
features: FeatureCollection,
|
|
357
|
+
people: People,
|
|
358
|
+
number_people_wanted: int,
|
|
359
|
+
settings: Settings,
|
|
360
|
+
) -> tuple[list[frozenset[str]], list[str]]:
|
|
361
|
+
"""Find any single feasible committee that satisfies the quotas.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
features: FeatureCollection with min/max quotas
|
|
365
|
+
people: People object with pool members
|
|
366
|
+
number_people_wanted: desired size of the panel
|
|
367
|
+
settings: Settings object containing configuration
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
tuple of (list containing one committee as frozenset of person_ids, empty list of messages)
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
InfeasibleQuotasError: If quotas are infeasible
|
|
374
|
+
SelectionError: If solver fails for other reasons
|
|
375
|
+
"""
|
|
376
|
+
model, agent_vars = _setup_committee_generation(features, people, number_people_wanted, settings)
|
|
377
|
+
committee = _ilp_results_to_committee(agent_vars)
|
|
378
|
+
return [committee], []
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _define_entitlements(
|
|
382
|
+
covered_agents: frozenset[str],
|
|
383
|
+
) -> tuple[list[str], dict[str, int]]:
|
|
384
|
+
"""Define entitlements mapping for agents who can be selected.
|
|
385
|
+
|
|
386
|
+
Creates a mapping from agent IDs to indices for use in matrix operations.
|
|
387
|
+
This is used by the fairness algorithms to track selection probabilities.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
covered_agents: frozenset of agent IDs who can potentially be selected
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
tuple of (entitlements list, contributes_to_entitlement mapping)
|
|
394
|
+
- entitlements: list of agent IDs in a fixed order
|
|
395
|
+
- contributes_to_entitlement: dict mapping agent_id -> index in entitlements list
|
|
396
|
+
"""
|
|
397
|
+
entitlements = list(covered_agents)
|
|
398
|
+
contributes_to_entitlement = {agent_id: entitlements.index(agent_id) for agent_id in covered_agents}
|
|
399
|
+
|
|
400
|
+
return entitlements, contributes_to_entitlement
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _committees_to_matrix(
|
|
404
|
+
committees: list[frozenset[str]],
|
|
405
|
+
entitlements: list[str],
|
|
406
|
+
contributes_to_entitlement: dict[str, int],
|
|
407
|
+
) -> np.ndarray:
|
|
408
|
+
"""Convert list of committees to a binary matrix for optimization algorithms.
|
|
409
|
+
|
|
410
|
+
Creates a binary matrix where entry (i,j) indicates whether agent entitlements[i]
|
|
411
|
+
is included in committee j. This matrix is used by fairness algorithms to optimize
|
|
412
|
+
selection probabilities.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
committees: list of committees, each committee is a frozenset of agent IDs
|
|
416
|
+
entitlements: list of agent IDs in a fixed order (from _define_entitlements)
|
|
417
|
+
contributes_to_entitlement: dict mapping agent_id -> index in entitlements
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
numpy array of shape (len(entitlements), len(committees)) where entry (i,j)
|
|
421
|
+
is 1 if agent entitlements[i] is in committee j, 0 otherwise
|
|
422
|
+
"""
|
|
423
|
+
columns = []
|
|
424
|
+
for committee in committees:
|
|
425
|
+
column = [0 for _ in entitlements]
|
|
426
|
+
for agent_id in committee:
|
|
427
|
+
column[contributes_to_entitlement[agent_id]] += 1
|
|
428
|
+
columns.append(np.array(column))
|
|
429
|
+
|
|
430
|
+
return np.column_stack(columns)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _update_multiplicative_weights_after_committee_found(
|
|
434
|
+
weights: dict[str, float],
|
|
435
|
+
new_committee: frozenset[str],
|
|
436
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
437
|
+
found_duplicate: bool,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Update multiplicative weights after finding a committee.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
weights: current weights for each agent (modified in-place)
|
|
443
|
+
new_committee: the committee that was just found
|
|
444
|
+
agent_vars: dict mapping agent_id to binary MIP variables
|
|
445
|
+
found_duplicate: True if this committee was already found before
|
|
446
|
+
"""
|
|
447
|
+
if not found_duplicate:
|
|
448
|
+
# Decrease the weight of each agent in the new committee by a constant factor
|
|
449
|
+
# As a result, future rounds will strongly prioritize including agents that appear in few committees
|
|
450
|
+
for agent_id in new_committee:
|
|
451
|
+
weights[agent_id] *= 0.8
|
|
452
|
+
else:
|
|
453
|
+
# If committee is already known, make all weights a bit more equal to mix things up
|
|
454
|
+
for agent_id in agent_vars:
|
|
455
|
+
weights[agent_id] = 0.9 * weights[agent_id] + 0.1
|
|
456
|
+
|
|
457
|
+
# Rescale the weights to prevent floating point problems
|
|
458
|
+
coefficient_sum = sum(weights.values())
|
|
459
|
+
for agent_id in agent_vars:
|
|
460
|
+
weights[agent_id] *= len(agent_vars) / coefficient_sum
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _run_multiplicative_weights_phase(
|
|
464
|
+
new_committee_model: mip.model.Model,
|
|
465
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
466
|
+
multiplicative_weights_rounds: int,
|
|
467
|
+
) -> tuple[set[frozenset[str]], set[str]]:
|
|
468
|
+
"""Run the multiplicative weights algorithm to find an initial diverse set of committees.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
new_committee_model: MIP model for finding committees
|
|
472
|
+
agent_vars: dict mapping agent_id to binary MIP variables
|
|
473
|
+
multiplicative_weights_rounds: number of rounds to run
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
tuple of (committees, covered_agents) - sets of committees found and agents covered
|
|
477
|
+
"""
|
|
478
|
+
committees: set[frozenset[str]] = set()
|
|
479
|
+
covered_agents: set[str] = set()
|
|
480
|
+
|
|
481
|
+
# Each agent has a weight between 0.99 and 1
|
|
482
|
+
# Note that if all start with weight `1` then we can end up with some committees having wrong number of results
|
|
483
|
+
weights = {agent_id: random_provider().uniform(0.99, 1.0) for agent_id in agent_vars}
|
|
484
|
+
|
|
485
|
+
for i in range(multiplicative_weights_rounds):
|
|
486
|
+
# Find a feasible committee such that the sum of weights of its members is maximal
|
|
487
|
+
new_committee_model.objective = mip.xsum(weights[agent_id] * agent_vars[agent_id] for agent_id in agent_vars)
|
|
488
|
+
new_committee_model.optimize()
|
|
489
|
+
new_committee = _ilp_results_to_committee(agent_vars)
|
|
490
|
+
|
|
491
|
+
# Check if this is a new committee
|
|
492
|
+
is_new_committee = new_committee not in committees
|
|
493
|
+
if is_new_committee:
|
|
494
|
+
committees.add(new_committee)
|
|
495
|
+
for agent_id in new_committee:
|
|
496
|
+
covered_agents.add(agent_id)
|
|
497
|
+
|
|
498
|
+
# Update weights based on whether we found a new committee
|
|
499
|
+
_update_multiplicative_weights_after_committee_found(weights, new_committee, agent_vars, not is_new_committee)
|
|
500
|
+
|
|
501
|
+
print(
|
|
502
|
+
f"Multiplicative weights phase, round {i + 1}/{multiplicative_weights_rounds}. "
|
|
503
|
+
f"Discovered {len(committees)} committees so far."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return committees, covered_agents
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _find_committees_for_uncovered_agents(
|
|
510
|
+
new_committee_model: mip.model.Model,
|
|
511
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
512
|
+
covered_agents: set[str],
|
|
513
|
+
) -> tuple[set[frozenset[str]], set[str], list[str]]:
|
|
514
|
+
"""Find committees that include any agents not yet covered by existing committees.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
new_committee_model: MIP model for finding committees
|
|
518
|
+
agent_vars: dict mapping agent_id to binary MIP variables
|
|
519
|
+
covered_agents: agents already covered by existing committees (modified in-place)
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
tuple of (new_committees, updated_covered_agents, output_lines)
|
|
523
|
+
"""
|
|
524
|
+
new_committees: set[frozenset[str]] = set()
|
|
525
|
+
output_lines = []
|
|
526
|
+
|
|
527
|
+
# Try to find a committee including each uncovered agent
|
|
528
|
+
for agent_id, agent_var in agent_vars.items():
|
|
529
|
+
if agent_id not in covered_agents:
|
|
530
|
+
new_committee_model.objective = agent_var # only care about this specific agent being included
|
|
531
|
+
new_committee_model.optimize()
|
|
532
|
+
new_committee = _ilp_results_to_committee(agent_vars)
|
|
533
|
+
|
|
534
|
+
if agent_id in new_committee:
|
|
535
|
+
new_committees.add(new_committee)
|
|
536
|
+
for covered_agent_id in new_committee:
|
|
537
|
+
covered_agents.add(covered_agent_id)
|
|
538
|
+
else:
|
|
539
|
+
output_lines.append(print_ret(f"Agent {agent_id} not contained in any feasible committee."))
|
|
540
|
+
|
|
541
|
+
return new_committees, covered_agents, output_lines
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _generate_initial_committees(
|
|
545
|
+
new_committee_model: mip.model.Model,
|
|
546
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
547
|
+
multiplicative_weights_rounds: int,
|
|
548
|
+
) -> tuple[set[frozenset[str]], frozenset[str], list[str]]:
|
|
549
|
+
"""To speed up the main iteration of the maximin and Nash algorithms, start from a diverse set of feasible
|
|
550
|
+
committees. In particular, each agent that can be included in any committee will be included in at least one of
|
|
551
|
+
these committees.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
new_committee_model: MIP model for finding committees
|
|
555
|
+
agent_vars: dict mapping agent_id to binary MIP variables
|
|
556
|
+
multiplicative_weights_rounds: number of rounds for the multiplicative weights phase
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
tuple of (committees, covered_agents, output_lines)
|
|
560
|
+
- committees: set of feasible committees discovered
|
|
561
|
+
- covered_agents: frozenset of all agents included in some committee
|
|
562
|
+
- output_lines: list of debug messages
|
|
563
|
+
"""
|
|
564
|
+
output_lines = []
|
|
565
|
+
|
|
566
|
+
# Phase 1: Use multiplicative weights algorithm to find diverse committees
|
|
567
|
+
committees, covered_agents = _run_multiplicative_weights_phase(
|
|
568
|
+
new_committee_model, agent_vars, multiplicative_weights_rounds
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Phase 2: Find committees for any agents not yet covered
|
|
572
|
+
additional_committees, covered_agents, coverage_output = _find_committees_for_uncovered_agents(
|
|
573
|
+
new_committee_model, agent_vars, covered_agents
|
|
574
|
+
)
|
|
575
|
+
committees.update(additional_committees)
|
|
576
|
+
output_lines.extend(coverage_output)
|
|
577
|
+
|
|
578
|
+
# Validation and final output
|
|
579
|
+
assert len(committees) >= 1 # We assume quotas are feasible at this stage
|
|
580
|
+
|
|
581
|
+
if len(covered_agents) == len(agent_vars):
|
|
582
|
+
output_lines.append(print_ret("All agents are contained in some feasible committee."))
|
|
583
|
+
|
|
584
|
+
return committees, frozenset(covered_agents), output_lines
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _find_maximin_primal(
|
|
588
|
+
committees: list[frozenset[str]],
|
|
589
|
+
covered_agents: frozenset[str],
|
|
590
|
+
) -> list[float]:
|
|
591
|
+
"""Find the optimal probabilities for committees that maximize the minimum selection probability.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
committees: list of feasible committees
|
|
595
|
+
covered_agents: frozenset of agents who can be selected
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
list of probabilities for each committee (same order as input)
|
|
599
|
+
"""
|
|
600
|
+
model = mip.Model(sense=mip.MAXIMIZE)
|
|
601
|
+
model.verbose = 0
|
|
602
|
+
|
|
603
|
+
committee_variables = [model.add_var(var_type=mip.CONTINUOUS, lb=0.0, ub=1.0) for _ in committees]
|
|
604
|
+
model.add_constr(mip.xsum(committee_variables) == 1)
|
|
605
|
+
|
|
606
|
+
agent_panel_variables: dict[str, list[Any]] = {agent_id: [] for agent_id in covered_agents}
|
|
607
|
+
for committee, var in zip(committees, committee_variables, strict=False):
|
|
608
|
+
for agent_id in committee:
|
|
609
|
+
if agent_id in covered_agents:
|
|
610
|
+
agent_panel_variables[agent_id].append(var)
|
|
611
|
+
|
|
612
|
+
lower = model.add_var(var_type=mip.CONTINUOUS, lb=0.0, ub=1.0)
|
|
613
|
+
|
|
614
|
+
for agent_variables in agent_panel_variables.values():
|
|
615
|
+
model.add_constr(lower <= mip.xsum(agent_variables))
|
|
616
|
+
model.objective = lower
|
|
617
|
+
model.optimize()
|
|
618
|
+
|
|
619
|
+
probabilities = [var.x for var in committee_variables]
|
|
620
|
+
probabilities = [max(p, 0) for p in probabilities]
|
|
621
|
+
sum_probabilities = sum(probabilities)
|
|
622
|
+
return [p / sum_probabilities for p in probabilities]
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _setup_maximin_incremental_model(
|
|
626
|
+
committees: set[frozenset[str]],
|
|
627
|
+
covered_agents: frozenset[str],
|
|
628
|
+
) -> tuple[mip.model.Model, dict[str, mip.entities.Var], mip.entities.Var]:
|
|
629
|
+
"""Set up the incremental LP model for maximin optimization.
|
|
630
|
+
|
|
631
|
+
The incremental model is an LP with a variable y_e for each entitlement e and one more variable z.
|
|
632
|
+
For an agent i, let e(i) denote her entitlement. Then, the LP is:
|
|
633
|
+
|
|
634
|
+
minimize z
|
|
635
|
+
s.t. Σ_{i ∈ B} y_{e(i)} ≤ z ∀ feasible committees B (*)
|
|
636
|
+
Σ_e y_e = 1
|
|
637
|
+
y_e ≥ 0 ∀ e
|
|
638
|
+
|
|
639
|
+
At any point in time, constraint (*) is only enforced for the committees in `committees`.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
committees: set of initial committees
|
|
643
|
+
covered_agents: agents that can be included in some committee
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
tuple of (incremental_model, incr_agent_vars, upper_bound_var)
|
|
647
|
+
"""
|
|
648
|
+
incremental_model = mip.Model(sense=mip.MINIMIZE)
|
|
649
|
+
incremental_model.verbose = 0
|
|
650
|
+
|
|
651
|
+
upper_bound = incremental_model.add_var(
|
|
652
|
+
var_type=mip.CONTINUOUS,
|
|
653
|
+
lb=0.0,
|
|
654
|
+
ub=mip.INF,
|
|
655
|
+
) # variable z
|
|
656
|
+
# variables y_e
|
|
657
|
+
incr_agent_vars = {
|
|
658
|
+
agent_id: incremental_model.add_var(var_type=mip.CONTINUOUS, lb=0.0, ub=1.0) for agent_id in covered_agents
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# Σ_e y_e = 1
|
|
662
|
+
incremental_model.add_constr(mip.xsum(incr_agent_vars.values()) == 1)
|
|
663
|
+
# minimize z
|
|
664
|
+
incremental_model.objective = upper_bound
|
|
665
|
+
|
|
666
|
+
for committee in committees:
|
|
667
|
+
committee_sum = mip.xsum([incr_agent_vars[agent_id] for agent_id in committee])
|
|
668
|
+
# Σ_{i ∈ B} y_{e(i)} ≤ z ∀ B ∈ `committees`
|
|
669
|
+
incremental_model.add_constr(committee_sum <= upper_bound)
|
|
670
|
+
|
|
671
|
+
return incremental_model, incr_agent_vars, upper_bound
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _run_maximin_heuristic_for_additional_committees(
|
|
675
|
+
new_committee_model: mip.model.Model,
|
|
676
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
677
|
+
incremental_model: mip.model.Model,
|
|
678
|
+
incr_agent_vars: dict[str, mip.entities.Var],
|
|
679
|
+
upper_bound_var: mip.entities.Var,
|
|
680
|
+
committees: set[frozenset[str]],
|
|
681
|
+
covered_agents: frozenset[str],
|
|
682
|
+
entitlement_weights: dict[str, float],
|
|
683
|
+
upper: float,
|
|
684
|
+
value: float,
|
|
685
|
+
) -> int:
|
|
686
|
+
"""Run heuristic to find additional committees without re-optimizing the incremental model.
|
|
687
|
+
|
|
688
|
+
Because optimizing `incremental_model` takes a long time, we would like to get multiple committees out
|
|
689
|
+
of a single run of `incremental_model`. Rather than reoptimizing for optimal y_e and z, we find some
|
|
690
|
+
feasible values y_e and z by modifying the old solution.
|
|
691
|
+
This heuristic only adds more committees, and does not influence correctness.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
new_committee_model: MIP model for finding new committees
|
|
695
|
+
agent_vars: agent variables in the committee model
|
|
696
|
+
incremental_model: the incremental LP model
|
|
697
|
+
incr_agent_vars: agent variables in incremental model
|
|
698
|
+
upper_bound_var: upper bound variable in incremental model
|
|
699
|
+
committees: set of committees (modified in-place)
|
|
700
|
+
covered_agents: agents that can be included
|
|
701
|
+
entitlement_weights: current entitlement weights (modified in-place)
|
|
702
|
+
upper: current upper bound value
|
|
703
|
+
value: current objective value
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
number of additional committees found
|
|
707
|
+
"""
|
|
708
|
+
counter = 0
|
|
709
|
+
new_set = None # Initialize to avoid UnboundLocalError
|
|
710
|
+
|
|
711
|
+
for _ in range(10):
|
|
712
|
+
# scale down the y_{e(i)} for i ∈ `new_set` to make Σ_{i ∈ `new_set`} y_{e(i)} ≤ z true
|
|
713
|
+
if new_set is not None: # Only scale if we have a new_set from previous iteration
|
|
714
|
+
for agent_id in new_set:
|
|
715
|
+
entitlement_weights[agent_id] *= upper / value
|
|
716
|
+
|
|
717
|
+
# This will change Σ_e y_e to be less than 1. We rescale the y_e and z
|
|
718
|
+
sum_weights = sum(entitlement_weights.values())
|
|
719
|
+
if sum_weights < EPS:
|
|
720
|
+
break
|
|
721
|
+
for agent_id in entitlement_weights:
|
|
722
|
+
entitlement_weights[agent_id] /= sum_weights
|
|
723
|
+
upper /= sum_weights
|
|
724
|
+
|
|
725
|
+
new_committee_model.objective = mip.xsum(
|
|
726
|
+
entitlement_weights[agent_id] * agent_vars[agent_id] for agent_id in covered_agents
|
|
727
|
+
)
|
|
728
|
+
new_committee_model.optimize()
|
|
729
|
+
new_set = _ilp_results_to_committee(agent_vars)
|
|
730
|
+
value = sum(entitlement_weights[agent_id] for agent_id in new_set)
|
|
731
|
+
if value <= upper + EPS or new_set in committees:
|
|
732
|
+
break
|
|
733
|
+
committees.add(new_set)
|
|
734
|
+
incremental_model.add_constr(mip.xsum(incr_agent_vars[agent_id] for agent_id in new_set) <= upper_bound_var)
|
|
735
|
+
counter += 1
|
|
736
|
+
|
|
737
|
+
return counter
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _run_maximin_optimization_loop(
|
|
741
|
+
new_committee_model: mip.model.Model,
|
|
742
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
743
|
+
incremental_model: mip.model.Model,
|
|
744
|
+
incr_agent_vars: dict[str, mip.entities.Var],
|
|
745
|
+
upper_bound_var: mip.entities.Var,
|
|
746
|
+
committees: set[frozenset[str]],
|
|
747
|
+
covered_agents: frozenset[str],
|
|
748
|
+
output_lines: list[str],
|
|
749
|
+
) -> tuple[list[frozenset[str]], list[float], list[str]]:
|
|
750
|
+
"""Run the main maximin optimization loop with column generation.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
new_committee_model: MIP model for finding new committees
|
|
754
|
+
agent_vars: agent variables in the committee model
|
|
755
|
+
incremental_model: the incremental LP model
|
|
756
|
+
incr_agent_vars: agent variables in incremental model
|
|
757
|
+
upper_bound_var: upper bound variable in incremental model
|
|
758
|
+
committees: set of committees (modified in-place)
|
|
759
|
+
covered_agents: agents that can be included
|
|
760
|
+
output_lines: list of output messages (modified in-place)
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
tuple of (committees, probabilities, output_lines)
|
|
764
|
+
"""
|
|
765
|
+
while True:
|
|
766
|
+
status = incremental_model.optimize()
|
|
767
|
+
assert status == mip.OptimizationStatus.OPTIMAL
|
|
768
|
+
|
|
769
|
+
# currently optimal values for y_e
|
|
770
|
+
entitlement_weights = {agent_id: incr_agent_vars[agent_id].x for agent_id in covered_agents}
|
|
771
|
+
upper = upper_bound_var.x # currently optimal value for z
|
|
772
|
+
|
|
773
|
+
# For these fixed y_e, find the feasible committee B with maximal Σ_{i ∈ B} y_{e(i)}
|
|
774
|
+
new_committee_model.objective = mip.xsum(
|
|
775
|
+
entitlement_weights[agent_id] * agent_vars[agent_id] for agent_id in covered_agents
|
|
776
|
+
)
|
|
777
|
+
new_committee_model.optimize()
|
|
778
|
+
new_set = _ilp_results_to_committee(agent_vars)
|
|
779
|
+
value = sum(entitlement_weights[agent_id] for agent_id in new_set)
|
|
780
|
+
|
|
781
|
+
output_lines.append(
|
|
782
|
+
print_ret(
|
|
783
|
+
f"Maximin is at most {value:.2%}, can do {upper:.2%} with {len(committees)} "
|
|
784
|
+
f"committees. Gap {value - upper:.2%}{'≤' if value - upper <= EPS else '>'}{EPS:%}."
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
if value <= upper + EPS:
|
|
788
|
+
# No feasible committee B violates Σ_{i ∈ B} y_{e(i)} ≤ z (at least up to EPS, to prevent rounding errors)
|
|
789
|
+
# Thus, we have enough committees
|
|
790
|
+
committee_list = list(committees)
|
|
791
|
+
probabilities = _find_maximin_primal(committee_list, covered_agents)
|
|
792
|
+
return committee_list, probabilities, output_lines
|
|
793
|
+
|
|
794
|
+
# Some committee B violates Σ_{i ∈ B} y_{e(i)} ≤ z. We add B to `committees` and recurse
|
|
795
|
+
assert new_set not in committees
|
|
796
|
+
committees.add(new_set)
|
|
797
|
+
incremental_model.add_constr(mip.xsum(incr_agent_vars[agent_id] for agent_id in new_set) <= upper_bound_var)
|
|
798
|
+
|
|
799
|
+
# Run heuristic to find additional committees
|
|
800
|
+
counter = _run_maximin_heuristic_for_additional_committees(
|
|
801
|
+
new_committee_model,
|
|
802
|
+
agent_vars,
|
|
803
|
+
incremental_model,
|
|
804
|
+
incr_agent_vars,
|
|
805
|
+
upper_bound_var,
|
|
806
|
+
committees,
|
|
807
|
+
covered_agents,
|
|
808
|
+
entitlement_weights,
|
|
809
|
+
upper,
|
|
810
|
+
value,
|
|
811
|
+
)
|
|
812
|
+
if counter > 0:
|
|
813
|
+
print(f"Heuristic successfully generated {counter} additional committees.")
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def find_distribution_maximin(
|
|
817
|
+
features: FeatureCollection,
|
|
818
|
+
people: People,
|
|
819
|
+
number_people_wanted: int,
|
|
820
|
+
settings: Settings,
|
|
821
|
+
) -> tuple[list[frozenset[str]], list[float], list[str]]:
|
|
822
|
+
"""Find a distribution over feasible committees that maximizes the minimum probability of an agent being selected.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
features: FeatureCollection with min/max quotas
|
|
826
|
+
people: People object with pool members
|
|
827
|
+
number_people_wanted: desired size of the panel
|
|
828
|
+
settings: Settings object containing configuration
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
tuple of (committees, probabilities, output_lines)
|
|
832
|
+
- committees: list of feasible committees (frozenset of agent IDs)
|
|
833
|
+
- probabilities: list of probabilities for each committee
|
|
834
|
+
- output_lines: list of debug strings
|
|
835
|
+
"""
|
|
836
|
+
output_lines = [print_ret("Using maximin algorithm.")]
|
|
837
|
+
|
|
838
|
+
# Set up an ILP that can be used for discovering new feasible committees
|
|
839
|
+
new_committee_model, agent_vars = _setup_committee_generation(features, people, number_people_wanted, settings)
|
|
840
|
+
|
|
841
|
+
# Find initial committees that cover every possible agent
|
|
842
|
+
committees, covered_agents, initial_output = _generate_initial_committees(
|
|
843
|
+
new_committee_model, agent_vars, people.count
|
|
844
|
+
)
|
|
845
|
+
output_lines += initial_output
|
|
846
|
+
|
|
847
|
+
# Set up the incremental LP model for column generation
|
|
848
|
+
incremental_model, incr_agent_vars, upper_bound_var = _setup_maximin_incremental_model(committees, covered_agents)
|
|
849
|
+
|
|
850
|
+
# Run the main optimization loop
|
|
851
|
+
return _run_maximin_optimization_loop(
|
|
852
|
+
new_committee_model,
|
|
853
|
+
agent_vars,
|
|
854
|
+
incremental_model,
|
|
855
|
+
incr_agent_vars,
|
|
856
|
+
upper_bound_var,
|
|
857
|
+
committees,
|
|
858
|
+
covered_agents,
|
|
859
|
+
output_lines,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _solve_nash_welfare_optimization(
|
|
864
|
+
committees: list[frozenset[str]],
|
|
865
|
+
entitlements: list[str],
|
|
866
|
+
contributes_to_entitlement: dict[str, int],
|
|
867
|
+
start_lambdas: list[float],
|
|
868
|
+
number_people_wanted: int,
|
|
869
|
+
output_lines: list[str],
|
|
870
|
+
) -> tuple[Any, np.ndarray, np.ndarray]:
|
|
871
|
+
"""Solve the Nash welfare optimization problem for current committees.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
committees: list of current committees
|
|
875
|
+
entitlements: list of agent entitlements
|
|
876
|
+
contributes_to_entitlement: mapping from agent_id to entitlement index
|
|
877
|
+
start_lambdas: starting probabilities for committees
|
|
878
|
+
number_people_wanted: desired committee size
|
|
879
|
+
output_lines: list of output messages (modified in-place)
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
tuple of (lambdas variable, entitled_reciprocals, differentials)
|
|
883
|
+
"""
|
|
884
|
+
lambdas = cp.Variable(len(committees)) # probability of outputting a specific committee
|
|
885
|
+
lambdas.value = start_lambdas
|
|
886
|
+
|
|
887
|
+
# A is a binary matrix, whose (i,j)th entry indicates whether agent `entitlements[i]`
|
|
888
|
+
# is included in committee j
|
|
889
|
+
matrix = _committees_to_matrix(committees, entitlements, contributes_to_entitlement)
|
|
890
|
+
assert matrix.shape == (len(entitlements), len(committees))
|
|
891
|
+
|
|
892
|
+
objective = cp.Maximize(cp.sum(cp.log(matrix @ lambdas)))
|
|
893
|
+
constraints = [lambdas >= 0, cp.sum(lambdas) == 1]
|
|
894
|
+
problem = cp.Problem(objective, constraints)
|
|
895
|
+
|
|
896
|
+
# Try SCS solver first, fall back to ECOS if it fails
|
|
897
|
+
try:
|
|
898
|
+
nash_welfare = problem.solve(solver=cp.SCS, warm_start=True)
|
|
899
|
+
except cp.SolverError:
|
|
900
|
+
# At least the ECOS solver in cvxpy crashes sometimes (numerical instabilities?).
|
|
901
|
+
# In this case, try another solver. But hope that SCS is more stable.
|
|
902
|
+
output_lines.append(print_ret("Had to switch to ECOS solver."))
|
|
903
|
+
nash_welfare = problem.solve(solver=cp.ECOS, warm_start=True)
|
|
904
|
+
|
|
905
|
+
scaled_welfare = nash_welfare - len(entitlements) * log(number_people_wanted / len(entitlements))
|
|
906
|
+
output_lines.append(print_ret(f"Scaled Nash welfare is now: {scaled_welfare}."))
|
|
907
|
+
|
|
908
|
+
assert lambdas.value.shape == (len(committees),) # type: ignore[union-attr]
|
|
909
|
+
entitled_utilities = matrix.dot(lambdas.value) # type: ignore[arg-type]
|
|
910
|
+
assert entitled_utilities.shape == (len(entitlements),)
|
|
911
|
+
assert (entitled_utilities > EPS2).all()
|
|
912
|
+
entitled_reciprocals = 1 / entitled_utilities
|
|
913
|
+
assert entitled_reciprocals.shape == (len(entitlements),)
|
|
914
|
+
differentials = entitled_reciprocals.dot(matrix)
|
|
915
|
+
assert differentials.shape == (len(committees),)
|
|
916
|
+
|
|
917
|
+
return lambdas, entitled_reciprocals, differentials
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _find_best_new_committee_for_nash(
|
|
921
|
+
new_committee_model: mip.model.Model,
|
|
922
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
923
|
+
entitled_reciprocals: np.ndarray,
|
|
924
|
+
contributes_to_entitlement: dict[str, int],
|
|
925
|
+
covered_agents: frozenset[str],
|
|
926
|
+
) -> tuple[frozenset[str], float]:
|
|
927
|
+
"""Find the committee that maximizes the Nash welfare objective.
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
new_committee_model: MIP model for finding committees
|
|
931
|
+
agent_vars: agent variables in the committee model
|
|
932
|
+
entitled_reciprocals: reciprocals of current utilities
|
|
933
|
+
contributes_to_entitlement: mapping from agent_id to entitlement index
|
|
934
|
+
covered_agents: agents that can be included
|
|
935
|
+
|
|
936
|
+
Returns:
|
|
937
|
+
tuple of (new_committee, objective_value)
|
|
938
|
+
"""
|
|
939
|
+
obj = [
|
|
940
|
+
entitled_reciprocals[contributes_to_entitlement[agent_id]] * agent_vars[agent_id] for agent_id in covered_agents
|
|
941
|
+
]
|
|
942
|
+
new_committee_model.objective = mip.xsum(obj)
|
|
943
|
+
new_committee_model.optimize()
|
|
944
|
+
|
|
945
|
+
new_set = _ilp_results_to_committee(agent_vars)
|
|
946
|
+
value = sum(entitled_reciprocals[contributes_to_entitlement[agent_id]] for agent_id in new_set)
|
|
947
|
+
|
|
948
|
+
return new_set, value
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _run_nash_optimization_loop(
|
|
952
|
+
new_committee_model: mip.model.Model,
|
|
953
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
954
|
+
committees: list[frozenset[str]],
|
|
955
|
+
entitlements: list[str],
|
|
956
|
+
contributes_to_entitlement: dict[str, int],
|
|
957
|
+
covered_agents: frozenset[str],
|
|
958
|
+
number_people_wanted: int,
|
|
959
|
+
output_lines: list[str],
|
|
960
|
+
) -> tuple[list[frozenset[str]], list[float], list[str]]:
|
|
961
|
+
"""Run the main Nash welfare optimization loop.
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
new_committee_model: MIP model for finding committees
|
|
965
|
+
agent_vars: agent variables in the committee model
|
|
966
|
+
committees: list of committees (modified in-place)
|
|
967
|
+
entitlements: list of agent entitlements
|
|
968
|
+
contributes_to_entitlement: mapping from agent_id to entitlement index
|
|
969
|
+
covered_agents: agents that can be included
|
|
970
|
+
number_people_wanted: desired committee size
|
|
971
|
+
output_lines: list of output messages (modified in-place)
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
tuple of (committees, probabilities, output_lines)
|
|
975
|
+
"""
|
|
976
|
+
start_lambdas = [1 / len(committees) for _ in committees]
|
|
977
|
+
|
|
978
|
+
while True:
|
|
979
|
+
# Solve Nash welfare optimization for current committees
|
|
980
|
+
lambdas, entitled_reciprocals, differentials = _solve_nash_welfare_optimization(
|
|
981
|
+
committees,
|
|
982
|
+
entitlements,
|
|
983
|
+
contributes_to_entitlement,
|
|
984
|
+
start_lambdas,
|
|
985
|
+
number_people_wanted,
|
|
986
|
+
output_lines,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# Find the best new committee
|
|
990
|
+
new_set, value = _find_best_new_committee_for_nash(
|
|
991
|
+
new_committee_model,
|
|
992
|
+
agent_vars,
|
|
993
|
+
entitled_reciprocals,
|
|
994
|
+
contributes_to_entitlement,
|
|
995
|
+
covered_agents,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
# Check convergence condition
|
|
999
|
+
if value <= differentials.max() + EPS_NASH:
|
|
1000
|
+
probabilities = np.array(lambdas.value).clip(0, 1)
|
|
1001
|
+
probabilities_normalised = list(probabilities / sum(probabilities))
|
|
1002
|
+
return committees, probabilities_normalised, output_lines
|
|
1003
|
+
|
|
1004
|
+
# Add new committee and continue
|
|
1005
|
+
print(value, differentials.max(), value - differentials.max())
|
|
1006
|
+
assert new_set not in committees
|
|
1007
|
+
committees.append(new_set)
|
|
1008
|
+
start_lambdas = [
|
|
1009
|
+
*list(np.array(lambdas.value)),
|
|
1010
|
+
0,
|
|
1011
|
+
] # Add 0 probability for new committee
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def find_distribution_nash(
|
|
1015
|
+
features: FeatureCollection,
|
|
1016
|
+
people: People,
|
|
1017
|
+
number_people_wanted: int,
|
|
1018
|
+
settings: Settings,
|
|
1019
|
+
) -> tuple[list[frozenset[str]], list[float], list[str]]:
|
|
1020
|
+
"""Find a distribution over feasible committees that maximizes the Nash welfare, i.e., the product of
|
|
1021
|
+
selection probabilities over all persons.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
features: FeatureCollection with min/max quotas
|
|
1025
|
+
people: People object with pool members
|
|
1026
|
+
number_people_wanted: desired size of the panel
|
|
1027
|
+
settings: Settings object containing configuration
|
|
1028
|
+
|
|
1029
|
+
Returns:
|
|
1030
|
+
tuple of (committees, probabilities, output_lines)
|
|
1031
|
+
- committees: list of feasible committees (frozenset of agent IDs)
|
|
1032
|
+
- probabilities: list of probabilities for each committee
|
|
1033
|
+
- output_lines: list of debug strings
|
|
1034
|
+
|
|
1035
|
+
The algorithm maximizes the product of selection probabilities Πᵢ pᵢ by equivalently maximizing
|
|
1036
|
+
log(Πᵢ pᵢ) = Σᵢ log(pᵢ). If some person i is not included in any feasible committee, their pᵢ is 0, and
|
|
1037
|
+
this sum is -∞. We maximize Σᵢ log(pᵢ) where i is restricted to range over persons that can possibly be included.
|
|
1038
|
+
"""
|
|
1039
|
+
output_lines = [print_ret("Using Nash algorithm.")]
|
|
1040
|
+
|
|
1041
|
+
# Set up an ILP used for discovering new feasible committees
|
|
1042
|
+
new_committee_model, agent_vars = _setup_committee_generation(features, people, number_people_wanted, settings)
|
|
1043
|
+
|
|
1044
|
+
# Find initial committees that include every possible agent
|
|
1045
|
+
committee_set, covered_agents, initial_output = _generate_initial_committees(
|
|
1046
|
+
new_committee_model, agent_vars, 2 * people.count
|
|
1047
|
+
)
|
|
1048
|
+
committees = list(committee_set)
|
|
1049
|
+
output_lines += initial_output
|
|
1050
|
+
|
|
1051
|
+
# Map the covered agents to indices in a list for easier matrix representation
|
|
1052
|
+
entitlements, contributes_to_entitlement = _define_entitlements(covered_agents)
|
|
1053
|
+
|
|
1054
|
+
# Run the main Nash welfare optimization loop
|
|
1055
|
+
return _run_nash_optimization_loop(
|
|
1056
|
+
new_committee_model,
|
|
1057
|
+
agent_vars,
|
|
1058
|
+
committees,
|
|
1059
|
+
entitlements,
|
|
1060
|
+
contributes_to_entitlement,
|
|
1061
|
+
covered_agents,
|
|
1062
|
+
number_people_wanted,
|
|
1063
|
+
output_lines,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _dual_leximin_stage(
|
|
1068
|
+
people: People,
|
|
1069
|
+
committees: set[frozenset[str]],
|
|
1070
|
+
fixed_probabilities: dict[str, float],
|
|
1071
|
+
) -> tuple:
|
|
1072
|
+
"""Implements the dual LP described in `find_distribution_leximin`, but where P only ranges over the panels
|
|
1073
|
+
in `committees` rather than over all feasible panels:
|
|
1074
|
+
minimize ŷ - Σ_{i in fixed_probabilities} fixed_probabilities[i] * yᵢ
|
|
1075
|
+
s.t. Σ_{i ∈ P} yᵢ ≤ ŷ ∀ P
|
|
1076
|
+
Σ_{i not in fixed_probabilities} yᵢ = 1
|
|
1077
|
+
ŷ, yᵢ ≥ 0 ∀ i
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
people: People object with all agents
|
|
1081
|
+
committees: set of feasible committees
|
|
1082
|
+
fixed_probabilities: dict mapping agent_id to fixed probability
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
tuple of (grb.Model, dict[str, grb.Var], grb.Var) - (model, agent_vars, cap_var)
|
|
1086
|
+
|
|
1087
|
+
Raises:
|
|
1088
|
+
RuntimeError: If Gurobi is not available
|
|
1089
|
+
"""
|
|
1090
|
+
if not GUROBI_AVAILABLE:
|
|
1091
|
+
msg = "Leximin algorithm requires Gurobi solver which is not available"
|
|
1092
|
+
raise RuntimeError(msg)
|
|
1093
|
+
|
|
1094
|
+
assert len(committees) != 0
|
|
1095
|
+
|
|
1096
|
+
model = grb.Model()
|
|
1097
|
+
agent_vars = {agent_id: model.addVar(vtype=grb.GRB.CONTINUOUS, lb=0.0) for agent_id in people} # yᵢ
|
|
1098
|
+
cap_var = model.addVar(vtype=grb.GRB.CONTINUOUS, lb=0.0) # ŷ
|
|
1099
|
+
model.addConstr(
|
|
1100
|
+
grb.quicksum(agent_vars[agent_id] for agent_id in people if agent_id not in fixed_probabilities) == 1
|
|
1101
|
+
)
|
|
1102
|
+
for committee in committees:
|
|
1103
|
+
model.addConstr(grb.quicksum(agent_vars[agent_id] for agent_id in committee) <= cap_var)
|
|
1104
|
+
model.setObjective(
|
|
1105
|
+
cap_var
|
|
1106
|
+
- grb.quicksum(fixed_probabilities[agent_id] * agent_vars[agent_id] for agent_id in fixed_probabilities),
|
|
1107
|
+
grb.GRB.MINIMIZE,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# Change Gurobi configuration to encourage strictly complementary ("inner") solutions. These solutions will
|
|
1111
|
+
# typically allow to fix more probabilities per outer loop of the leximin algorithm.
|
|
1112
|
+
model.setParam("Method", 2) # optimize via barrier only
|
|
1113
|
+
model.setParam("Crossover", 0) # deactivate cross-over
|
|
1114
|
+
|
|
1115
|
+
return model, agent_vars, cap_var
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _run_leximin_column_generation_loop(
|
|
1119
|
+
new_committee_model: mip.model.Model,
|
|
1120
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
1121
|
+
dual_model: Any, # grb.Model
|
|
1122
|
+
dual_agent_vars: dict[str, Any], # dict[str, grb.Var]
|
|
1123
|
+
dual_cap_var: Any, # grb.Var
|
|
1124
|
+
committees: set[frozenset[str]],
|
|
1125
|
+
fixed_probabilities: dict[str, float],
|
|
1126
|
+
people: People,
|
|
1127
|
+
reduction_counter: int,
|
|
1128
|
+
output_lines: list[str],
|
|
1129
|
+
) -> tuple[bool, int]:
|
|
1130
|
+
"""Run the column generation inner loop for leximin optimization.
|
|
1131
|
+
|
|
1132
|
+
The primal LP being solved by column generation, with a variable x_P for each feasible panel P:
|
|
1133
|
+
|
|
1134
|
+
maximize z
|
|
1135
|
+
s.t. Σ_{P : i ∈ P} x_P ≥ z ∀ i not in fixed_probabilities
|
|
1136
|
+
Σ_{P : i ∈ P} x_P ≥ fixed_probabilities[i] ∀ i in fixed_probabilities
|
|
1137
|
+
Σ_P x_P ≤ 1 (This should be thought of as equality, and wlog.
|
|
1138
|
+
optimal solutions have equality, but simplifies dual)
|
|
1139
|
+
x_P ≥ 0 ∀ P
|
|
1140
|
+
|
|
1141
|
+
We instead solve its dual linear program:
|
|
1142
|
+
minimize ŷ - Σ_{i in fixed_probabilities} fixed_probabilities[i] * yᵢ
|
|
1143
|
+
s.t. Σ_{i ∈ P} yᵢ ≤ ŷ ∀ P
|
|
1144
|
+
Σ_{i not in fixed_probabilities} yᵢ = 1
|
|
1145
|
+
ŷ, yᵢ ≥ 0 ∀ i
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
new_committee_model: MIP model for finding committees
|
|
1149
|
+
agent_vars: agent variables in the committee model
|
|
1150
|
+
dual_model: Gurobi model for dual LP
|
|
1151
|
+
dual_agent_vars: agent variables in dual model
|
|
1152
|
+
dual_cap_var: capacity variable in dual model
|
|
1153
|
+
committees: set of committees (modified in-place)
|
|
1154
|
+
fixed_probabilities: probabilities that have been fixed (modified in-place)
|
|
1155
|
+
people: People object with all agents
|
|
1156
|
+
reduction_counter: counter for probability reductions
|
|
1157
|
+
output_lines: list of output messages (modified in-place)
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
tuple of (should_break_outer_loop, updated_reduction_counter)
|
|
1161
|
+
"""
|
|
1162
|
+
while True:
|
|
1163
|
+
dual_model.optimize()
|
|
1164
|
+
if dual_model.status != grb.GRB.OPTIMAL:
|
|
1165
|
+
# In theory, the LP is feasible in the first iterations, and we only add constraints (by fixing
|
|
1166
|
+
# probabilities) that preserve feasibility. Due to floating-point issues, however, it may happen that
|
|
1167
|
+
# Gurobi still cannot satisfy all the fixed probabilities in the primal (meaning that the dual will be
|
|
1168
|
+
# unbounded). In this case, we slightly relax the LP by slightly reducing all fixed probabilities.
|
|
1169
|
+
for agent_id in fixed_probabilities:
|
|
1170
|
+
# Relax all fixed probabilities by a small constant
|
|
1171
|
+
fixed_probabilities[agent_id] = max(0.0, fixed_probabilities[agent_id] - 0.0001)
|
|
1172
|
+
dual_model, dual_agent_vars, dual_cap_var = _dual_leximin_stage(
|
|
1173
|
+
people,
|
|
1174
|
+
committees,
|
|
1175
|
+
fixed_probabilities,
|
|
1176
|
+
)
|
|
1177
|
+
print(dual_model.status, f"REDUCE PROBS for {reduction_counter}th time.")
|
|
1178
|
+
reduction_counter += 1
|
|
1179
|
+
continue
|
|
1180
|
+
|
|
1181
|
+
# Find the panel P for which Σ_{i ∈ P} yᵢ is largest, i.e., for which Σ_{i ∈ P} yᵢ ≤ ŷ is tightest
|
|
1182
|
+
agent_weights = {agent_id: agent_var.x for agent_id, agent_var in dual_agent_vars.items()}
|
|
1183
|
+
new_committee_model.objective = mip.xsum(agent_weights[agent_id] * agent_vars[agent_id] for agent_id in people)
|
|
1184
|
+
new_committee_model.optimize()
|
|
1185
|
+
new_set = _ilp_results_to_committee(agent_vars) # panel P
|
|
1186
|
+
value = new_committee_model.objective_value # Σ_{i ∈ P} yᵢ
|
|
1187
|
+
|
|
1188
|
+
upper = dual_cap_var.x # ŷ
|
|
1189
|
+
dual_obj = dual_model.objVal # ŷ - Σ_{i in fixed_probabilities} fixed_probabilities[i] * yᵢ
|
|
1190
|
+
|
|
1191
|
+
output_lines.append(
|
|
1192
|
+
print_ret(
|
|
1193
|
+
f"Maximin is at most {dual_obj - upper + value:.2%}, can do {dual_obj:.2%} with "
|
|
1194
|
+
f"{len(committees)} committees. Gap {value - upper:.2%}."
|
|
1195
|
+
)
|
|
1196
|
+
)
|
|
1197
|
+
if value <= upper + EPS:
|
|
1198
|
+
# Within numeric tolerance, the panels in `committees` are enough to constrain the dual, i.e., they are
|
|
1199
|
+
# enough to support an optimal primal solution.
|
|
1200
|
+
for agent_id, agent_weight in agent_weights.items():
|
|
1201
|
+
if agent_weight > EPS and agent_id not in fixed_probabilities:
|
|
1202
|
+
# `agent_weight` is the dual variable yᵢ of the constraint "Σ_{P : i ∈ P} x_P ≥ z" for
|
|
1203
|
+
# i = `agent_id` in the primal LP. If yᵢ is positive, this means that the constraint must be
|
|
1204
|
+
# binding in all optimal solutions [1], and we can fix `agent_id`'s probability to the
|
|
1205
|
+
# optimal value of the primal/dual LP.
|
|
1206
|
+
# [1] Theorem 3.3 in: Renato Pelessoni. Some remarks on the use of the strict complementarity in
|
|
1207
|
+
# checking coherence and extending coherent probabilities. 1998.
|
|
1208
|
+
fixed_probabilities[agent_id] = max(0, dual_obj)
|
|
1209
|
+
return True, reduction_counter # Break outer loop
|
|
1210
|
+
|
|
1211
|
+
# Given that Σ_{i ∈ P} yᵢ > ŷ, the current solution to `dual_model` is not yet a solution to the dual.
|
|
1212
|
+
# Thus, add the constraint for panel P and recurse.
|
|
1213
|
+
assert new_set not in committees
|
|
1214
|
+
committees.add(new_set)
|
|
1215
|
+
dual_model.addConstr(grb.quicksum(dual_agent_vars[agent_id] for agent_id in new_set) <= dual_cap_var)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _solve_leximin_primal_for_final_probabilities(
|
|
1219
|
+
committees: set[frozenset[str]], fixed_probabilities: dict[str, float]
|
|
1220
|
+
) -> list[float]:
|
|
1221
|
+
"""Solve the final primal problem to get committee probabilities from fixed agent probabilities.
|
|
1222
|
+
|
|
1223
|
+
The previous algorithm computed the leximin selection probabilities of each agent and a set of panels such that
|
|
1224
|
+
the selection probabilities can be obtained by randomizing over these panels. Here, such a randomization is found.
|
|
1225
|
+
|
|
1226
|
+
Args:
|
|
1227
|
+
committees: set of committees
|
|
1228
|
+
fixed_probabilities: fixed probabilities for each agent
|
|
1229
|
+
|
|
1230
|
+
Returns:
|
|
1231
|
+
list of normalized probabilities for each committee
|
|
1232
|
+
"""
|
|
1233
|
+
primal = grb.Model()
|
|
1234
|
+
# Variables for the output probabilities of the different panels
|
|
1235
|
+
committee_vars = [primal.addVar(vtype=grb.GRB.CONTINUOUS, lb=0.0) for _ in committees]
|
|
1236
|
+
# To avoid numerical problems, we formally minimize the largest downward deviation from the fixed probabilities.
|
|
1237
|
+
eps = primal.addVar(vtype=grb.GRB.CONTINUOUS, lb=0.0)
|
|
1238
|
+
primal.addConstr(grb.quicksum(committee_vars) == 1) # Probabilities add up to 1
|
|
1239
|
+
for agent_id, prob in fixed_probabilities.items():
|
|
1240
|
+
agent_probability = grb.quicksum(
|
|
1241
|
+
comm_var for committee, comm_var in zip(committees, committee_vars, strict=False) if agent_id in committee
|
|
1242
|
+
)
|
|
1243
|
+
primal.addConstr(agent_probability >= prob - eps)
|
|
1244
|
+
primal.setObjective(eps, grb.GRB.MINIMIZE)
|
|
1245
|
+
primal.optimize()
|
|
1246
|
+
|
|
1247
|
+
# Bound variables between 0 and 1 and renormalize, because np.random.choice is sensitive to small deviations here
|
|
1248
|
+
probabilities = np.array([comm_var.x for comm_var in committee_vars]).clip(0, 1)
|
|
1249
|
+
return list(probabilities / sum(probabilities))
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _run_leximin_main_loop(
|
|
1253
|
+
new_committee_model: mip.model.Model,
|
|
1254
|
+
agent_vars: dict[str, mip.entities.Var],
|
|
1255
|
+
committees: set[frozenset[str]],
|
|
1256
|
+
people: People,
|
|
1257
|
+
output_lines: list[str],
|
|
1258
|
+
) -> dict[str, float]:
|
|
1259
|
+
"""Run the main leximin optimization loop that fixes probabilities iteratively.
|
|
1260
|
+
|
|
1261
|
+
The outer loop maximizes the minimum of all unfixed probabilities while satisfying the fixed probabilities.
|
|
1262
|
+
In each iteration, at least one more probability is fixed, but often more than one.
|
|
1263
|
+
|
|
1264
|
+
Args:
|
|
1265
|
+
new_committee_model: MIP model for finding committees
|
|
1266
|
+
agent_vars: agent variables in the committee model
|
|
1267
|
+
committees: set of committees (modified in-place)
|
|
1268
|
+
people: People object with all agents
|
|
1269
|
+
output_lines: list of output messages (modified in-place)
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
dict mapping agent_id to fixed probability
|
|
1273
|
+
"""
|
|
1274
|
+
fixed_probabilities: dict[str, float] = {}
|
|
1275
|
+
reduction_counter = 0
|
|
1276
|
+
|
|
1277
|
+
while len(fixed_probabilities) < people.count:
|
|
1278
|
+
print(f"Fixed {len(fixed_probabilities)}/{people.count} probabilities.")
|
|
1279
|
+
|
|
1280
|
+
dual_model, dual_agent_vars, dual_cap_var = _dual_leximin_stage(
|
|
1281
|
+
people,
|
|
1282
|
+
committees,
|
|
1283
|
+
fixed_probabilities,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
# Run column generation inner loop
|
|
1287
|
+
should_break, reduction_counter = _run_leximin_column_generation_loop(
|
|
1288
|
+
new_committee_model,
|
|
1289
|
+
agent_vars,
|
|
1290
|
+
dual_model,
|
|
1291
|
+
dual_agent_vars,
|
|
1292
|
+
dual_cap_var,
|
|
1293
|
+
committees,
|
|
1294
|
+
fixed_probabilities,
|
|
1295
|
+
people,
|
|
1296
|
+
reduction_counter,
|
|
1297
|
+
output_lines,
|
|
1298
|
+
)
|
|
1299
|
+
if should_break:
|
|
1300
|
+
break
|
|
1301
|
+
|
|
1302
|
+
return fixed_probabilities
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def find_distribution_leximin(
|
|
1306
|
+
features: FeatureCollection,
|
|
1307
|
+
people: People,
|
|
1308
|
+
number_people_wanted: int,
|
|
1309
|
+
settings: Settings,
|
|
1310
|
+
) -> tuple[list[frozenset[str]], list[float], list[str]]:
|
|
1311
|
+
"""Find a distribution over feasible committees that maximizes the minimum probability of an agent being selected
|
|
1312
|
+
(just like maximin), but breaks ties to maximize the second-lowest probability, breaks further ties to maximize the
|
|
1313
|
+
third-lowest probability and so forth.
|
|
1314
|
+
|
|
1315
|
+
Args:
|
|
1316
|
+
features: FeatureCollection with min/max quotas
|
|
1317
|
+
people: People object with pool members
|
|
1318
|
+
number_people_wanted: desired size of the panel
|
|
1319
|
+
settings: Settings object containing configuration
|
|
1320
|
+
|
|
1321
|
+
Returns:
|
|
1322
|
+
tuple of (committees, probabilities, output_lines)
|
|
1323
|
+
- committees: list of feasible committees (frozenset of agent IDs)
|
|
1324
|
+
- probabilities: list of probabilities for each committee
|
|
1325
|
+
- output_lines: list of debug strings
|
|
1326
|
+
|
|
1327
|
+
Raises:
|
|
1328
|
+
RuntimeError: If Gurobi is not available
|
|
1329
|
+
"""
|
|
1330
|
+
if not GUROBI_AVAILABLE:
|
|
1331
|
+
msg = "Leximin algorithm requires Gurobi solver which is not available"
|
|
1332
|
+
raise RuntimeError(msg)
|
|
1333
|
+
|
|
1334
|
+
output_lines = [print_ret("Using leximin algorithm.")]
|
|
1335
|
+
grb.setParam("OutputFlag", 0)
|
|
1336
|
+
|
|
1337
|
+
# Set up an ILP that can be used for discovering new feasible committees
|
|
1338
|
+
new_committee_model, agent_vars = _setup_committee_generation(features, people, number_people_wanted, settings)
|
|
1339
|
+
|
|
1340
|
+
# Find initial committees that cover every possible agent
|
|
1341
|
+
committees, covered_agents, initial_output = _generate_initial_committees(
|
|
1342
|
+
new_committee_model, agent_vars, 3 * people.count
|
|
1343
|
+
)
|
|
1344
|
+
output_lines += initial_output
|
|
1345
|
+
|
|
1346
|
+
# Run the main leximin optimization loop to fix agent probabilities
|
|
1347
|
+
fixed_probabilities = _run_leximin_main_loop(new_committee_model, agent_vars, committees, people, output_lines)
|
|
1348
|
+
|
|
1349
|
+
# Convert fixed agent probabilities to committee probabilities
|
|
1350
|
+
probabilities_normalised = _solve_leximin_primal_for_final_probabilities(committees, fixed_probabilities)
|
|
1351
|
+
|
|
1352
|
+
return list(committees), probabilities_normalised, output_lines
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def standardize_distribution(
|
|
1356
|
+
committees: list[frozenset[str]],
|
|
1357
|
+
probabilities: list[float],
|
|
1358
|
+
) -> tuple[list[frozenset[str]], list[float]]:
|
|
1359
|
+
"""Remove committees with zero probability and renormalize.
|
|
1360
|
+
|
|
1361
|
+
Args:
|
|
1362
|
+
committees: list of committees
|
|
1363
|
+
probabilities: corresponding probabilities
|
|
1364
|
+
|
|
1365
|
+
Returns:
|
|
1366
|
+
tuple of (filtered_committees, normalized_probabilities)
|
|
1367
|
+
"""
|
|
1368
|
+
assert len(committees) == len(probabilities)
|
|
1369
|
+
new_committees = []
|
|
1370
|
+
new_probabilities = []
|
|
1371
|
+
for committee, prob in zip(committees, probabilities, strict=False):
|
|
1372
|
+
if prob >= EPS2:
|
|
1373
|
+
new_committees.append(committee)
|
|
1374
|
+
new_probabilities.append(prob)
|
|
1375
|
+
prob_sum = sum(new_probabilities)
|
|
1376
|
+
new_probabilities = [prob / prob_sum for prob in new_probabilities]
|
|
1377
|
+
return new_committees, new_probabilities
|