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.
@@ -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