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,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
|