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,591 @@
1
+ from collections.abc import Iterable
2
+ from copy import deepcopy
3
+
4
+ from sortition_algorithms import errors
5
+ from sortition_algorithms.committee_generation import (
6
+ EPS2,
7
+ GUROBI_AVAILABLE,
8
+ find_any_committee,
9
+ find_distribution_leximin,
10
+ find_distribution_maximin,
11
+ find_distribution_nash,
12
+ standardize_distribution,
13
+ )
14
+ from sortition_algorithms.features import FeatureCollection
15
+ from sortition_algorithms.people import People
16
+ from sortition_algorithms.people_features import simple_add_selected
17
+ from sortition_algorithms.settings import Settings
18
+ from sortition_algorithms.utils import print_ret, random_provider, set_random_provider
19
+
20
+
21
+ def multi_selection_to_table(multi_selections: list[frozenset[str]]) -> list[list[str]]:
22
+ header_row = [f"Assembly {index}" for index in range(len(multi_selections))]
23
+ # put all the assemblies in columns of the output
24
+ return [header_row, *(list(selection_keys) for selection_keys in multi_selections)]
25
+
26
+
27
+ def person_list_to_table(
28
+ person_keys: Iterable[str],
29
+ people: People,
30
+ features: FeatureCollection,
31
+ settings: Settings,
32
+ ) -> list[list[str]]:
33
+ cols_to_use = settings.columns_to_keep[:]
34
+ # we want to avoid duplicate columns if they are in both features and columns_to_keep
35
+ extra_features = [name for name in features.feature_names if name not in cols_to_use]
36
+ cols_to_use += extra_features
37
+ rows = [[settings.id_column, *cols_to_use]]
38
+ for pkey in person_keys:
39
+ person_dict = people.get_person_dict(pkey)
40
+ rows.append([pkey, *(person_dict[col] for col in cols_to_use)])
41
+ return rows
42
+
43
+
44
+ def selected_remaining_tables(
45
+ full_people: People,
46
+ people_selected: frozenset[str],
47
+ features: FeatureCollection,
48
+ settings: Settings,
49
+ ) -> tuple[list[list[str]], list[list[str]], list[str]]:
50
+ """
51
+ write some text
52
+
53
+ people_selected is a single frozenset[str] - it must be unwrapped before being passed
54
+ to this function.
55
+ """
56
+ people_working = deepcopy(full_people)
57
+ output_lines: list[str] = []
58
+
59
+ people_selected_rows = person_list_to_table(people_selected, people_working, features, settings)
60
+
61
+ # now delete the selected people (and maybe also those at the same address)
62
+ num_same_address_deleted = 0
63
+ for pkey in people_selected:
64
+ # if check address then delete all those at this address (will NOT delete the one we want as well)
65
+ if settings.check_same_address:
66
+ pkey_to_delete = list(people_working.matching_address(pkey, settings.check_same_address_columns))
67
+ num_same_address_deleted += len(pkey_to_delete) + 1
68
+ # then delete this/these people at the same address from the reserve/remaining pool
69
+ people_working.remove_many([pkey, *pkey_to_delete])
70
+ else:
71
+ people_working.remove(pkey)
72
+
73
+ # add the columns to keep into remaining people
74
+ # as above all these values are all in people_working but this is tidier...
75
+ people_remaining_rows = person_list_to_table(people_working, people_working, features, settings)
76
+ return people_selected_rows, people_remaining_rows, output_lines
77
+
78
+ # TODO: put this code somewhere more suitable
79
+ # maybe in strat app only?
80
+ """
81
+ dupes = self._output_selected_remaining(
82
+ settings,
83
+ people_selected_rows,
84
+ people_remaining_rows,
85
+ )
86
+ if settings.check_same_address and self.gen_rem_tab == "on":
87
+ output_lines.append(
88
+ f"Deleted {num_same_address_deleted} people from remaining file who had the same "
89
+ f"address as selected people.",
90
+ )
91
+ m = min(30, len(dupes))
92
+ output_lines.append(
93
+ f"In the remaining tab there are {len(dupes)} people who share the same address as "
94
+ f"someone else in the tab. We highlighted the first {m} of these. "
95
+ f"The full list of lines is {dupes}",
96
+ )
97
+ """
98
+
99
+
100
+ def pipage_rounding(marginals: list[tuple[int, float]]) -> list[int]:
101
+ """Pipage rounding algorithm for converting fractional solutions to integer solutions.
102
+
103
+ Takes a list of (object, probability) pairs and randomly rounds them to a set of objects
104
+ such that the expected number of times each object appears equals its probability.
105
+
106
+ Args:
107
+ marginals: list of (object, probability) pairs where probabilities sum to an integer
108
+
109
+ Returns:
110
+ list of objects that were selected
111
+ """
112
+ assert all(0.0 <= p <= 1.0 for _, p in marginals)
113
+
114
+ outcomes: list[int] = []
115
+ while True:
116
+ if len(marginals) == 0:
117
+ return outcomes
118
+ if len(marginals) == 1:
119
+ obj, prob = marginals[0]
120
+ if random_provider().uniform(0.0, 1.0) < prob:
121
+ outcomes.append(obj)
122
+ marginals = []
123
+ else:
124
+ obj0, prob0 = marginals[0]
125
+ if prob0 > 1.0 - EPS2:
126
+ outcomes.append(obj0)
127
+ marginals = marginals[1:]
128
+ continue
129
+ if prob0 < EPS2:
130
+ marginals = marginals[1:]
131
+ continue
132
+
133
+ obj1, prob1 = marginals[1]
134
+ if prob1 > 1.0 - EPS2:
135
+ outcomes.append(obj1)
136
+ marginals = [marginals[0]] + marginals[2:]
137
+ continue
138
+ if prob1 < EPS2:
139
+ marginals = [marginals[0]] + marginals[2:]
140
+ continue
141
+
142
+ inc0_dec1_amount = min(
143
+ 1.0 - prob0, prob1
144
+ ) # maximal amount that prob0 can be increased and prob1 can be decreased
145
+ dec0_inc1_amount = min(prob0, 1.0 - prob1)
146
+ choice_probability = dec0_inc1_amount / (inc0_dec1_amount + dec0_inc1_amount)
147
+
148
+ if random_provider().uniform(0.0, 1.0) < choice_probability: # increase prob0 and decrease prob1
149
+ prob0 += inc0_dec1_amount
150
+ prob1 -= inc0_dec1_amount
151
+ else:
152
+ prob0 -= dec0_inc1_amount
153
+ prob1 += dec0_inc1_amount
154
+ marginals = [(obj0, prob0), (obj1, prob1)] + marginals[2:]
155
+
156
+
157
+ def lottery_rounding(
158
+ committees: list[frozenset[str]],
159
+ probabilities: list[float],
160
+ number_selections: int,
161
+ ) -> list[frozenset[str]]:
162
+ """Convert probability distribution over committees to a discrete lottery.
163
+
164
+ Args:
165
+ committees: list of committees
166
+ probabilities: corresponding probabilities (must sum to 1)
167
+ number_selections: number of committees to return
168
+
169
+ Returns:
170
+ list of committees (may contain duplicates) of length number_selections
171
+ """
172
+ assert len(committees) == len(probabilities)
173
+ assert number_selections >= 1
174
+
175
+ num_copies: list[int] = []
176
+ residuals: list[float] = []
177
+ for _, prob in zip(committees, probabilities, strict=False):
178
+ scaled_prob = prob * number_selections
179
+ num_copies.append(int(scaled_prob)) # give lower quotas
180
+ residuals.append(scaled_prob - int(scaled_prob))
181
+
182
+ rounded_up_indices = pipage_rounding(list(enumerate(residuals)))
183
+ for committee_index in rounded_up_indices:
184
+ num_copies[committee_index] += 1
185
+
186
+ committee_lottery: list[frozenset[str]] = []
187
+ for committee, committee_copies in zip(committees, num_copies, strict=False):
188
+ committee_lottery += [committee for _ in range(committee_copies)]
189
+
190
+ return committee_lottery
191
+
192
+
193
+ def _distribution_stats(
194
+ people: People,
195
+ committees: list[frozenset[str]],
196
+ probabilities: list[float],
197
+ ) -> list[str]:
198
+ """Generate statistics about the distribution over committees.
199
+
200
+ Args:
201
+ people: People object
202
+ committees: list of committees
203
+ probabilities: corresponding probabilities
204
+
205
+ Returns:
206
+ list of output lines with statistics
207
+ """
208
+ output_lines = []
209
+
210
+ assert len(committees) == len(probabilities)
211
+ num_non_zero = sum(1 for prob in probabilities if prob > 0)
212
+ output_lines.append(
213
+ f"Algorithm produced distribution over {len(committees)} committees, out of which "
214
+ f"{num_non_zero} are chosen with positive probability."
215
+ )
216
+
217
+ individual_probabilities = dict.fromkeys(people, 0.0)
218
+ containing_committees: dict[str, list[frozenset[str]]] = {agent_id: [] for agent_id in people}
219
+ for committee, prob in zip(committees, probabilities, strict=False):
220
+ if prob > 0:
221
+ for agent_id in committee:
222
+ individual_probabilities[agent_id] += prob
223
+ containing_committees[agent_id].append(committee)
224
+
225
+ table = [
226
+ "<table border='1' cellpadding='5'><tr><th>Agent ID</th><th>Probability of selection</th><th>Included in # of committees</th></tr>"
227
+ ]
228
+
229
+ for _, agent_id in sorted((prob, agent_id) for agent_id, prob in individual_probabilities.items()):
230
+ table.append(
231
+ f"<tr><td>{agent_id}</td><td>{individual_probabilities[agent_id]:.4%}</td><td>{len(containing_committees[agent_id])}</td></tr>"
232
+ )
233
+ table.append("</table>")
234
+ output_lines.append("".join(table))
235
+
236
+ return output_lines
237
+
238
+
239
+ def find_random_sample(
240
+ features: FeatureCollection,
241
+ people: People,
242
+ number_people_wanted: int,
243
+ settings: Settings,
244
+ selection_algorithm: str = "maximin",
245
+ test_selection: bool = False,
246
+ number_selections: int = 1,
247
+ ) -> tuple[list[frozenset[str]], list[str]]:
248
+ """Main algorithm to find one or multiple random committees.
249
+
250
+ Args:
251
+ features: FeatureCollection with min/max quotas
252
+ people: People object with pool members
253
+ number_people_wanted: desired size of the panel
254
+ settings: Settings object containing configuration
255
+ selection_algorithm: one of "legacy", "maximin", "leximin", or "nash"
256
+ test_selection: if set, do not do a random selection, but just return some valid panel.
257
+ Useful for quickly testing whether quotas are satisfiable, but should always be false for actual selection!
258
+ number_selections: how many panels to return. Most of the time, this should be set to 1, which means that
259
+ a single panel is chosen. When specifying a value n ≥ 2, the function will return a list of length n,
260
+ containing multiple panels (some panels might be repeated in the list). In this case the eventual panel
261
+ should be drawn uniformly at random from the returned list.
262
+
263
+ Returns:
264
+ tuple of (committee_lottery, output_lines)
265
+ - committee_lottery: list of committees, where each committee is a frozen set of pool member ids
266
+ - output_lines: list of debug strings
267
+
268
+ Raises:
269
+ InfeasibleQuotasError: if the quotas cannot be satisfied, which includes a suggestion for how to modify them
270
+ SelectionError: in multiple other failure cases
271
+ ValueError: for invalid parameters
272
+ RuntimeError: if required solver is not available
273
+ """
274
+ # Input validation
275
+ if test_selection and number_selections != 1:
276
+ msg = (
277
+ "Running the test selection does not support generating a transparent lottery, so, if "
278
+ "`test_selection` is true, `number_selections` must be 1."
279
+ )
280
+ raise ValueError(msg)
281
+
282
+ if selection_algorithm == "legacy" and number_selections != 1:
283
+ msg = (
284
+ "Currently, the legacy algorithm does not support generating a transparent lottery, "
285
+ "so `number_selections` must be set to 1."
286
+ )
287
+ raise ValueError(msg)
288
+
289
+ # Quick test selection using find_any_committee
290
+ if test_selection:
291
+ print("Running test selection.")
292
+ return find_any_committee(features, people, number_people_wanted, settings)
293
+
294
+ output_lines = []
295
+
296
+ # Check if Gurobi is available for leximin
297
+ if selection_algorithm == "leximin" and not GUROBI_AVAILABLE:
298
+ output_lines.append(
299
+ print_ret(
300
+ "The leximin algorithm requires the optimization library Gurobi to be installed "
301
+ "(commercial, free academic licenses available). Switching to the simpler "
302
+ "maximin algorithm, which can be run using open source solvers."
303
+ )
304
+ )
305
+ selection_algorithm = "maximin"
306
+
307
+ # Route to appropriate algorithm
308
+ if selection_algorithm == "legacy":
309
+ # Import here to avoid circular imports
310
+ from sortition_algorithms.find_sample import find_random_sample_legacy
311
+
312
+ return find_random_sample_legacy(
313
+ people,
314
+ features,
315
+ number_people_wanted,
316
+ settings.check_same_address,
317
+ settings.check_same_address_columns,
318
+ )
319
+ elif selection_algorithm == "leximin":
320
+ committees, probabilities, new_output_lines = find_distribution_leximin(
321
+ features, people, number_people_wanted, settings
322
+ )
323
+ elif selection_algorithm == "maximin":
324
+ committees, probabilities, new_output_lines = find_distribution_maximin(
325
+ features, people, number_people_wanted, settings
326
+ )
327
+ elif selection_algorithm == "nash":
328
+ committees, probabilities, new_output_lines = find_distribution_nash(
329
+ features, people, number_people_wanted, settings
330
+ )
331
+ else:
332
+ msg = (
333
+ f"Unknown selection algorithm {selection_algorithm!r}, must be either 'legacy', 'leximin', "
334
+ f"'maximin', or 'nash'."
335
+ )
336
+ raise ValueError(msg)
337
+
338
+ # Post-process the distribution
339
+ committees, probabilities = standardize_distribution(committees, probabilities)
340
+ if len(committees) > people.count:
341
+ print(
342
+ "INFO: The distribution over panels is what is known as a 'basic solution'. There is no reason for concern "
343
+ "about the correctness of your output, but we'd appreciate if you could reach out to panelot"
344
+ f"@paulgoelz.de with the following information: algorithm={selection_algorithm}, "
345
+ f"num_panels={len(committees)}, num_agents={people.count}, min_probs={min(probabilities)}."
346
+ )
347
+
348
+ assert len(set(committees)) == len(committees)
349
+
350
+ output_lines += new_output_lines
351
+ output_lines += _distribution_stats(people, committees, probabilities)
352
+
353
+ # Convert to lottery
354
+ committee_lottery = lottery_rounding(committees, probabilities, number_selections)
355
+
356
+ return committee_lottery, output_lines
357
+
358
+
359
+ def _initial_print_category_info(
360
+ features: FeatureCollection,
361
+ people: People,
362
+ ) -> list[str]:
363
+ """Generate HTML table showing category/feature statistics.
364
+
365
+ Args:
366
+ features: FeatureCollection with min/max targets
367
+ people: People object with pool members
368
+ number_people_wanted: Target number of people to select
369
+
370
+ Returns:
371
+ List containing HTML table as single string
372
+ """
373
+ # Build HTML table header
374
+ report_msg = [
375
+ "<table border='1' cellpadding='5'><tr><th colspan='2'>Category</th><th>Initially</th><th>Want</th></tr>"
376
+ ]
377
+ # Make a working copy and update counts
378
+ features_working = deepcopy(features)
379
+ simple_add_selected(people, people, features_working)
380
+
381
+ # Generate table rows
382
+ for feature, value, fv_counts in features_working.feature_values_counts():
383
+ report_msg.append(
384
+ f"<tr><td>{feature}</td><td>{value}</td>"
385
+ f"<td>{fv_counts.selected}</td><td>[{fv_counts.min},{fv_counts.max}]</td></tr>"
386
+ )
387
+
388
+ report_msg.append("</table>")
389
+ return ["".join(report_msg)]
390
+
391
+
392
+ def _print_category_info(
393
+ features: FeatureCollection,
394
+ people: People,
395
+ people_selected: list[frozenset[str]],
396
+ number_people_wanted: int,
397
+ ) -> list[str]:
398
+ """Generate HTML table showing category/feature statistics.
399
+
400
+ Args:
401
+ features: FeatureCollection with min/max targets
402
+ people: People object with pool members
403
+ people_selected: List of selected committees (empty for initial state)
404
+ number_people_wanted: Target number of people to select
405
+
406
+ Returns:
407
+ List containing HTML table as single string
408
+ """
409
+ if len(people_selected) != 1:
410
+ return [
411
+ "<p>We do not calculate target details for multiple selections - please see your output files.</p>",
412
+ ]
413
+
414
+ # Build HTML table header
415
+ report_msg = [
416
+ "<table border='1' cellpadding='5'><tr><th colspan='2'>Category</th><th>Selected</th><th>Want</th></tr>"
417
+ ]
418
+
419
+ # Make a working copy and update counts
420
+ features_working = deepcopy(features)
421
+ simple_add_selected(people_selected[0], people, features_working)
422
+
423
+ # Generate table rows
424
+ for feature, value, fv_counts in features_working.feature_values_counts():
425
+ percent_selected = fv_counts.percent_selected(number_people_wanted)
426
+ report_msg.append(
427
+ f"<tr><td>{feature}</td><td>{value}</td>"
428
+ f"<td>{fv_counts.selected} ({percent_selected:.2f}%)</td>"
429
+ f"<td>[{fv_counts.min},{fv_counts.max}]</td></tr>"
430
+ )
431
+
432
+ report_msg.append("</table>")
433
+ return ["".join(report_msg)]
434
+
435
+
436
+ def _check_category_selected(
437
+ features: FeatureCollection,
438
+ people: People,
439
+ people_selected: list[frozenset[str]],
440
+ number_selections: int,
441
+ ) -> tuple[bool, list[str]]:
442
+ """Check if selected committee meets all feature value targets.
443
+
444
+ Args:
445
+ features: FeatureCollection with min/max targets
446
+ people: People object with pool members
447
+ people_selected: List of selected committees
448
+ number_selections: Number of selections made
449
+
450
+ Returns:
451
+ Tuple of (success, output_messages)
452
+ """
453
+ if number_selections > 1:
454
+ return True, [
455
+ "<p>No target checks done for multiple selections - please see your output files.</p>",
456
+ ]
457
+
458
+ if len(people_selected) != 1:
459
+ return True, [""]
460
+
461
+ hit_targets = True
462
+ last_feature_fail = ""
463
+
464
+ # Make working copy and count selected people
465
+ from copy import deepcopy
466
+
467
+ features_working = deepcopy(features)
468
+
469
+ simple_add_selected(people_selected[0], people, features_working)
470
+
471
+ # Check if quotas are met
472
+ for (
473
+ feature_name,
474
+ value_name,
475
+ value_counts,
476
+ ) in features_working.feature_values_counts():
477
+ if value_counts.selected < value_counts.min or value_counts.selected > value_counts.max:
478
+ hit_targets = False
479
+ last_feature_fail = f"{feature_name}: {value_name}"
480
+
481
+ report_msg = (
482
+ ""
483
+ if hit_targets
484
+ else f"<p>Failed to get minimum or got more than maximum in (at least) category: {last_feature_fail}</p>"
485
+ )
486
+ return hit_targets, [report_msg]
487
+
488
+
489
+ def run_stratification(
490
+ features: FeatureCollection,
491
+ people: People,
492
+ number_people_wanted: int,
493
+ settings: Settings,
494
+ test_selection: bool = False,
495
+ number_selections: int = 1,
496
+ ) -> tuple[bool, list[frozenset[str]], list[str]]:
497
+ """Run stratified random selection with retry logic.
498
+
499
+ Args:
500
+ features: FeatureCollection with min/max quotas for each feature value
501
+ people: People object containing the pool of candidates
502
+ number_people_wanted: Desired size of the panel
503
+ settings: Settings object containing configuration
504
+ test_selection: If True, don't randomize (for testing only)
505
+ number_selections: Number of panels to return
506
+
507
+ Returns:
508
+ Tuple of (success, selected_committees, output_lines)
509
+ - success: Whether selection succeeded within max attempts
510
+ - selected_committees: List of committees (frozensets of person IDs)
511
+ - output_lines: Debug and status messages
512
+
513
+ Raises:
514
+ Exception: If number_people_wanted is outside valid range for any feature
515
+ ValueError: For invalid parameters
516
+ RuntimeError: If required solver is not available
517
+ InfeasibleQuotasError: If quotas cannot be satisfied
518
+ """
519
+ # Check if desired number is within feature constraints
520
+ features.check_desired(number_people_wanted)
521
+
522
+ # Set random seed if specified
523
+ # If the seed is zero or None, we use the secrets module, as it is better
524
+ # from a security point of view
525
+ set_random_provider(settings.random_number_seed)
526
+
527
+ success = False
528
+ output_lines = []
529
+
530
+ if test_selection:
531
+ output_lines.append(
532
+ "<b style='color: red'>WARNING: Panel is not selected at random! Only use for testing!</b><br>",
533
+ )
534
+
535
+ output_lines.append("<b>Initial: (selected = 0)</b>")
536
+ output_lines += _initial_print_category_info(
537
+ features,
538
+ people,
539
+ )
540
+ people_selected: list[frozenset[str]] = []
541
+
542
+ tries = 0
543
+ for tries in range(settings.max_attempts):
544
+ people_selected = []
545
+
546
+ output_lines.append(f"<b>Trial number: {tries}</b>")
547
+
548
+ try:
549
+ people_selected, new_output_lines = find_random_sample(
550
+ features,
551
+ people,
552
+ number_people_wanted,
553
+ settings,
554
+ settings.selection_algorithm,
555
+ test_selection,
556
+ number_selections,
557
+ )
558
+ output_lines += new_output_lines
559
+
560
+ # Check if targets were met (only works for number_selections = 1)
561
+ new_output_lines = _print_category_info(
562
+ features,
563
+ people,
564
+ people_selected,
565
+ number_people_wanted,
566
+ )
567
+ success, check_output_lines = _check_category_selected(
568
+ features,
569
+ people,
570
+ people_selected,
571
+ number_selections,
572
+ )
573
+
574
+ if success:
575
+ output_lines.append("<b>SUCCESS!!</b> Final:")
576
+ output_lines += new_output_lines + check_output_lines
577
+ break
578
+
579
+ except (ValueError, RuntimeError) as err:
580
+ output_lines.append(str(err))
581
+ break
582
+ except errors.InfeasibleQuotasError as err:
583
+ output_lines += err.output
584
+ break
585
+ except errors.SelectionError as serr:
586
+ output_lines.append(f"Failed: Selection Error thrown: {serr}")
587
+
588
+ if not success:
589
+ output_lines.append(f"Failed {tries} times... gave up.")
590
+
591
+ return success, people_selected, output_lines
@@ -0,0 +1,26 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from sortition_algorithms.features import FeatureCollection
5
+
6
+
7
+ class BadDataError(Exception):
8
+ pass
9
+
10
+
11
+ class SelectionError(Exception):
12
+ pass
13
+
14
+
15
+ class InfeasibleQuotasError(Exception):
16
+ def __init__(self, features: "FeatureCollection", output: list[str]) -> None:
17
+ self.features = features
18
+ self.output = ["The quotas are infeasible:", *output]
19
+
20
+ def __str__(self) -> str:
21
+ return "\n".join(self.output)
22
+
23
+
24
+ class InfeasibleQuotasCantRelaxError(Exception):
25
+ def __init__(self, message: str) -> None:
26
+ self.message = message