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,351 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import Iterable, Iterator
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from attrs import define
|
|
6
|
+
|
|
7
|
+
from sortition_algorithms import errors, utils
|
|
8
|
+
|
|
9
|
+
# TODO: put this in docs and link to them from here.
|
|
10
|
+
"""
|
|
11
|
+
Note on terminology. The word "categories" can mean various things, so for this
|
|
12
|
+
code we use the terms "Feature" and "Value".
|
|
13
|
+
|
|
14
|
+
- Feature example: gender
|
|
15
|
+
- Value examples: male, female, non-binary
|
|
16
|
+
|
|
17
|
+
In the UI we may use the terms
|
|
18
|
+
|
|
19
|
+
- "category" to mean "feature" and
|
|
20
|
+
- "bucket" to mean "value".
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
FEATURE_FILE_FIELD_NAMES = ("feature", "value", "min", "max")
|
|
24
|
+
FEATURE_FILE_FIELD_NAMES_FLEX = (
|
|
25
|
+
*FEATURE_FILE_FIELD_NAMES,
|
|
26
|
+
"min_flex",
|
|
27
|
+
"max_flex",
|
|
28
|
+
)
|
|
29
|
+
FEATURE_FILE_FIELD_NAMES_OLD = ("category", "name", "min", "max")
|
|
30
|
+
FEATURE_FILE_FIELD_NAMES_FLEX_OLD = (
|
|
31
|
+
*FEATURE_FILE_FIELD_NAMES_OLD,
|
|
32
|
+
"min_flex",
|
|
33
|
+
"max_flex",
|
|
34
|
+
)
|
|
35
|
+
ALL_FEATURE_FIELD_NAMES = frozenset([*FEATURE_FILE_FIELD_NAMES_FLEX, *FEATURE_FILE_FIELD_NAMES_OLD])
|
|
36
|
+
|
|
37
|
+
MAX_FLEX_UNSET = -1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@define(kw_only=True, slots=True)
|
|
41
|
+
class FeatureValueCounts:
|
|
42
|
+
min: int
|
|
43
|
+
max: int
|
|
44
|
+
selected: int = 0
|
|
45
|
+
remaining: int = 0
|
|
46
|
+
min_flex: int = 0
|
|
47
|
+
max_flex: int = MAX_FLEX_UNSET
|
|
48
|
+
|
|
49
|
+
def set_default_max_flex(self, max_flex: int) -> None:
|
|
50
|
+
# this must be bigger than the largest max - and could even be more than number of people
|
|
51
|
+
if self.max_flex == MAX_FLEX_UNSET:
|
|
52
|
+
self.max_flex = max_flex
|
|
53
|
+
|
|
54
|
+
def add_remaining(self) -> None:
|
|
55
|
+
self.remaining += 1
|
|
56
|
+
|
|
57
|
+
def add_selected(self) -> None:
|
|
58
|
+
self.selected += 1
|
|
59
|
+
|
|
60
|
+
def remove_remaining(self) -> None:
|
|
61
|
+
self.remaining -= 1
|
|
62
|
+
if self.remaining == 0 and self.selected < self.min:
|
|
63
|
+
msg = "SELECTION IMPOSSIBLE: FAIL - no one/not enough left after deletion."
|
|
64
|
+
raise errors.SelectionError(msg)
|
|
65
|
+
|
|
66
|
+
def percent_selected(self, number_people_wanted: int) -> float:
|
|
67
|
+
return self.selected * 100 / float(number_people_wanted)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FeatureValues:
|
|
71
|
+
"""
|
|
72
|
+
A full set of values for a single feature.
|
|
73
|
+
|
|
74
|
+
If the feature is gender, the values could be: male, female, non_binary_other
|
|
75
|
+
|
|
76
|
+
The values are FeatureValueCounts objects - the min, max and current counts of the
|
|
77
|
+
selected people in that feature value.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self.feature_values: dict[str, FeatureValueCounts] = {}
|
|
82
|
+
|
|
83
|
+
def __eq__(self, other: Any) -> bool:
|
|
84
|
+
if not isinstance(other, self.__class__):
|
|
85
|
+
return False
|
|
86
|
+
return self.feature_values == other.feature_values
|
|
87
|
+
|
|
88
|
+
def add_value_counts(self, value_name: str, fv_counts: FeatureValueCounts) -> None:
|
|
89
|
+
self.feature_values[value_name] = fv_counts
|
|
90
|
+
|
|
91
|
+
def set_default_max_flex(self, max_flex: int) -> None:
|
|
92
|
+
"""Note this only sets it if left at the default value"""
|
|
93
|
+
for fv_counts in self.feature_values.values():
|
|
94
|
+
fv_counts.set_default_max_flex(max_flex)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def values(self) -> list[str]:
|
|
98
|
+
return list(self.feature_values.keys())
|
|
99
|
+
|
|
100
|
+
def values_counts(self) -> Iterator[tuple[str, FeatureValueCounts]]:
|
|
101
|
+
yield from self.feature_values.items()
|
|
102
|
+
|
|
103
|
+
def add_remaining(self, value_name: str) -> None:
|
|
104
|
+
self.feature_values[value_name].add_remaining()
|
|
105
|
+
|
|
106
|
+
def add_selected(self, value_name: str) -> None:
|
|
107
|
+
self.feature_values[value_name].add_selected()
|
|
108
|
+
|
|
109
|
+
def remove_remaining(self, value_name: str) -> None:
|
|
110
|
+
self.feature_values[value_name].remove_remaining()
|
|
111
|
+
|
|
112
|
+
def minimum_selection(self) -> int:
|
|
113
|
+
"""
|
|
114
|
+
For this feature, we have to select at least the sum of the minimum of each value
|
|
115
|
+
"""
|
|
116
|
+
return sum(c.min for c in self.feature_values.values())
|
|
117
|
+
|
|
118
|
+
def maximum_selection(self) -> int:
|
|
119
|
+
"""
|
|
120
|
+
For this feature, we have to select at most the sum of the maximum of each value
|
|
121
|
+
"""
|
|
122
|
+
return sum(c.max for c in self.feature_values.values())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class FeatureCollection:
|
|
126
|
+
"""
|
|
127
|
+
A full set of features for a stratification.
|
|
128
|
+
|
|
129
|
+
The keys here are the names of the features. They could be: gender, age_bracket, education_level etc
|
|
130
|
+
|
|
131
|
+
The values are FeatureValues objects - the breakdown of the values for a feature.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# TODO: consider splitting the updates/remaining into a parallel set of classes
|
|
135
|
+
# then this can just have targets, and the running totals can be in classes we can
|
|
136
|
+
# regenerate now and then
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self.collection: dict[str, FeatureValues] = defaultdict(FeatureValues)
|
|
140
|
+
|
|
141
|
+
def __eq__(self, other: Any) -> bool:
|
|
142
|
+
if not isinstance(other, self.__class__):
|
|
143
|
+
return False
|
|
144
|
+
return self.collection == other.collection
|
|
145
|
+
|
|
146
|
+
def add_feature(self, feature_name: str, value_name: str, fv_counts: FeatureValueCounts) -> None:
|
|
147
|
+
self.collection[feature_name].add_value_counts(value_name, fv_counts)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def feature_names(self) -> list[str]:
|
|
151
|
+
return list(self.collection.keys())
|
|
152
|
+
|
|
153
|
+
def feature_values(self) -> Iterator[tuple[str, list[str]]]:
|
|
154
|
+
for feature_name, feature_value in self.collection.items():
|
|
155
|
+
yield feature_name, feature_value.values
|
|
156
|
+
|
|
157
|
+
def feature_values_counts(self) -> Iterator[tuple[str, str, FeatureValueCounts]]:
|
|
158
|
+
for feature_name, feature_values in self.collection.items():
|
|
159
|
+
for value, value_counts in feature_values.values_counts():
|
|
160
|
+
yield feature_name, value, value_counts
|
|
161
|
+
|
|
162
|
+
def _safe_max_flex_val(self) -> int:
|
|
163
|
+
if not self.collection:
|
|
164
|
+
return 0
|
|
165
|
+
# to avoid errors, if max_flex is not set we must set it at least as high as the highest
|
|
166
|
+
return max(v.maximum_selection() for v in self.collection.values())
|
|
167
|
+
|
|
168
|
+
def set_default_max_flex(self) -> None:
|
|
169
|
+
"""Note this only sets it if left at the default value"""
|
|
170
|
+
max_flex = self._safe_max_flex_val()
|
|
171
|
+
for feature_values in self.collection.values():
|
|
172
|
+
feature_values.set_default_max_flex(max_flex)
|
|
173
|
+
|
|
174
|
+
def add_remaining(self, feature: str, value_name: str) -> None:
|
|
175
|
+
self.collection[feature].add_remaining(value_name)
|
|
176
|
+
|
|
177
|
+
def add_selected(self, feature: str, value_name: str) -> None:
|
|
178
|
+
self.collection[feature].add_selected(value_name)
|
|
179
|
+
|
|
180
|
+
def remove_remaining(self, feature: str, value_name: str) -> None:
|
|
181
|
+
try:
|
|
182
|
+
self.collection[feature].remove_remaining(value_name)
|
|
183
|
+
except errors.SelectionError as e:
|
|
184
|
+
msg = f"Failed removing from {feature}/{value_name}: {e}"
|
|
185
|
+
raise errors.SelectionError(msg) from None
|
|
186
|
+
|
|
187
|
+
def minimum_selection(self) -> int:
|
|
188
|
+
"""
|
|
189
|
+
The minimum selection for this set of features is the largest minimum selection
|
|
190
|
+
of any individual feature.
|
|
191
|
+
"""
|
|
192
|
+
if not self.collection:
|
|
193
|
+
return 0
|
|
194
|
+
return max(v.minimum_selection() for v in self.collection.values())
|
|
195
|
+
|
|
196
|
+
def maximum_selection(self) -> int:
|
|
197
|
+
"""
|
|
198
|
+
The maximum selection for this set of features is the smallest maximum selection
|
|
199
|
+
of any individual feature.
|
|
200
|
+
"""
|
|
201
|
+
if not self.collection:
|
|
202
|
+
return 0
|
|
203
|
+
return min(v.maximum_selection() for v in self.collection.values())
|
|
204
|
+
|
|
205
|
+
def check_min_max(self) -> None:
|
|
206
|
+
"""
|
|
207
|
+
If the min is bigger than the max we're in trouble i.e. there's an input error
|
|
208
|
+
"""
|
|
209
|
+
if self.minimum_selection() > self.maximum_selection():
|
|
210
|
+
msg = (
|
|
211
|
+
"Inconsistent numbers in min and max in the features input: the sum "
|
|
212
|
+
"of the minimum values of a features is larger than the sum of the "
|
|
213
|
+
"maximum values of a(nother) feature. "
|
|
214
|
+
)
|
|
215
|
+
raise ValueError(msg)
|
|
216
|
+
|
|
217
|
+
def check_desired(self, desired_number: int) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Check if the desired number of people is within the min/max of every feature.
|
|
220
|
+
"""
|
|
221
|
+
for feature_name, feature_values in self.collection.items():
|
|
222
|
+
if (
|
|
223
|
+
desired_number < feature_values.minimum_selection()
|
|
224
|
+
or desired_number > feature_values.maximum_selection()
|
|
225
|
+
):
|
|
226
|
+
msg = (
|
|
227
|
+
f"The number of people to select ({desired_number}) is out of the range of "
|
|
228
|
+
f"the numbers of people in the {feature_name} feature. It should be within "
|
|
229
|
+
f"[{feature_values.minimum_selection()}, {feature_values.maximum_selection()}]."
|
|
230
|
+
)
|
|
231
|
+
raise Exception(msg)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _normalise_col_names(row: dict[str, str]) -> dict[str, str]:
|
|
235
|
+
"""
|
|
236
|
+
if the dict has "category" as the key, change that to "feature"
|
|
237
|
+
if the dict has "name" as the key, change that to "value"
|
|
238
|
+
"""
|
|
239
|
+
if "category" in row:
|
|
240
|
+
row["feature"] = row.pop("category")
|
|
241
|
+
if "name" in row:
|
|
242
|
+
row["value"] = row.pop("name")
|
|
243
|
+
return row
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _feature_headers_flex(headers: list[str]) -> tuple[bool, list[str]]:
|
|
247
|
+
"""
|
|
248
|
+
Determine if the headers match either the required ones, or required plus flex fields.
|
|
249
|
+
Return True if the flex headers are present, False if not.
|
|
250
|
+
|
|
251
|
+
It is fine to have extra headers - they will just be ignored.
|
|
252
|
+
|
|
253
|
+
If an invalid set of headers are present, report details.
|
|
254
|
+
"""
|
|
255
|
+
filtered_headers = [h for h in headers if h in ALL_FEATURE_FIELD_NAMES]
|
|
256
|
+
# check that the fieldnames are (at least) what we expect, and only once.
|
|
257
|
+
# BUT (for reverse compatibility) let min_flex and max_flex be optional.
|
|
258
|
+
if sorted(filtered_headers) in (
|
|
259
|
+
sorted(FEATURE_FILE_FIELD_NAMES),
|
|
260
|
+
sorted(FEATURE_FILE_FIELD_NAMES_OLD),
|
|
261
|
+
):
|
|
262
|
+
return False, filtered_headers
|
|
263
|
+
if sorted(filtered_headers) in (
|
|
264
|
+
sorted(FEATURE_FILE_FIELD_NAMES_FLEX),
|
|
265
|
+
sorted(FEATURE_FILE_FIELD_NAMES_FLEX_OLD),
|
|
266
|
+
):
|
|
267
|
+
return True, filtered_headers
|
|
268
|
+
# below here we are reporting errors with the headers
|
|
269
|
+
messages: list[str] = []
|
|
270
|
+
required_fields = FEATURE_FILE_FIELD_NAMES if "feature" in filtered_headers else FEATURE_FILE_FIELD_NAMES_OLD
|
|
271
|
+
for field_name in required_fields:
|
|
272
|
+
feature_head_field_name_count = filtered_headers.count(field_name)
|
|
273
|
+
if feature_head_field_name_count == 0 and (field_name != "min_flex" and field_name != "max_flex"):
|
|
274
|
+
messages.append(f"Did not find required column name '{field_name}' in the input")
|
|
275
|
+
elif feature_head_field_name_count > 1:
|
|
276
|
+
messages.append(
|
|
277
|
+
f"Found MORE THAN 1 column named '{field_name}' in the input (found {feature_head_field_name_count})"
|
|
278
|
+
)
|
|
279
|
+
msg = "\n".join(messages) if messages else f"Unexpected error in set of column names: {headers}"
|
|
280
|
+
raise ValueError(msg)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _clean_row(row: utils.StrippedDict, feature_flex: bool) -> tuple[str, str, FeatureValueCounts]:
|
|
284
|
+
"""
|
|
285
|
+
allow for some dirty data - at least strip white space from feature name and value
|
|
286
|
+
but only if they are strings! (sometimes people use ints as feature names or values
|
|
287
|
+
and then strip produces an exception...)
|
|
288
|
+
"""
|
|
289
|
+
feature_name = row["feature"]
|
|
290
|
+
# check for blank entries and report a meaningful error
|
|
291
|
+
feature_value = row["value"]
|
|
292
|
+
if feature_value == "" or row["min"] == "" or row["max"] == "":
|
|
293
|
+
msg = f"ERROR reading in feature file: found a blank cell in a row of the feature: {feature_name}."
|
|
294
|
+
raise ValueError(msg)
|
|
295
|
+
# must convert min/max to ints
|
|
296
|
+
value_min = int(row["min"])
|
|
297
|
+
value_max = int(row["max"])
|
|
298
|
+
if feature_flex:
|
|
299
|
+
if row["min_flex"] == "" or row["max_flex"] == "":
|
|
300
|
+
msg = (
|
|
301
|
+
f"ERROR reading in feature file: found a blank min_flex or "
|
|
302
|
+
f"max_flex cell in a feature value: {feature_value}."
|
|
303
|
+
)
|
|
304
|
+
raise ValueError(msg)
|
|
305
|
+
value_min_flex = int(row["min_flex"])
|
|
306
|
+
value_max_flex = int(row["max_flex"])
|
|
307
|
+
# if these values exist they must be at least this...
|
|
308
|
+
if value_min_flex > value_min or value_max_flex < value_max:
|
|
309
|
+
msg = (
|
|
310
|
+
f"Inconsistent numbers in min_flex and max_flex in the features input for {feature_value}: "
|
|
311
|
+
f"the flex values must be equal or outside the max and min values."
|
|
312
|
+
)
|
|
313
|
+
raise ValueError(msg)
|
|
314
|
+
else:
|
|
315
|
+
value_min_flex = 0
|
|
316
|
+
# since we don't know self.number_people_to_select yet! We correct this below
|
|
317
|
+
value_max_flex = MAX_FLEX_UNSET
|
|
318
|
+
fv_counts = FeatureValueCounts(
|
|
319
|
+
min=value_min,
|
|
320
|
+
max=value_max,
|
|
321
|
+
min_flex=value_min_flex,
|
|
322
|
+
max_flex=value_max_flex,
|
|
323
|
+
)
|
|
324
|
+
return feature_name, feature_value, fv_counts
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_in_features(
|
|
328
|
+
features_head: Iterable[str], features_body: Iterable[dict[str, str]]
|
|
329
|
+
) -> tuple[FeatureCollection, list[str]]:
|
|
330
|
+
"""
|
|
331
|
+
Read in stratified selection features and values
|
|
332
|
+
|
|
333
|
+
Note we do want features_head to ensure we don't have multiple columns with the same name
|
|
334
|
+
"""
|
|
335
|
+
features = FeatureCollection()
|
|
336
|
+
msg: list[str] = []
|
|
337
|
+
features_flex, filtered_headers = _feature_headers_flex(list(features_head))
|
|
338
|
+
for row in features_body:
|
|
339
|
+
# check the set of keys in the row are the same as the headers
|
|
340
|
+
assert set(filtered_headers) <= set(row.keys())
|
|
341
|
+
stripped_row = utils.StrippedDict(_normalise_col_names(row))
|
|
342
|
+
if not stripped_row["feature"]:
|
|
343
|
+
continue
|
|
344
|
+
features.add_feature(*_clean_row(stripped_row, features_flex))
|
|
345
|
+
|
|
346
|
+
msg.append(f"Number of features: {len(features.feature_names)}")
|
|
347
|
+
features.check_min_max()
|
|
348
|
+
# check feature_flex to see if we need to set the max here
|
|
349
|
+
# this only changes the max_flex value if these (optional) flex values are NOT set already
|
|
350
|
+
features.set_default_max_flex()
|
|
351
|
+
return features, msg
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Selection algorithms for stratified sampling."""
|
|
2
|
+
|
|
3
|
+
from sortition_algorithms import errors
|
|
4
|
+
from sortition_algorithms.features import FeatureCollection
|
|
5
|
+
from sortition_algorithms.people import People
|
|
6
|
+
from sortition_algorithms.people_features import PeopleFeatures
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find_random_sample_legacy(
|
|
10
|
+
people: People,
|
|
11
|
+
features: FeatureCollection,
|
|
12
|
+
number_people_wanted: int,
|
|
13
|
+
check_same_address: bool = False,
|
|
14
|
+
check_same_address_columns: list[str] | None = None,
|
|
15
|
+
) -> tuple[list[frozenset[str]], list[str]]:
|
|
16
|
+
"""
|
|
17
|
+
Legacy stratified random selection algorithm.
|
|
18
|
+
|
|
19
|
+
Implements the original algorithm that uses greedy selection based on priority ratios.
|
|
20
|
+
Always selects from the most urgently needed category first (highest ratio of
|
|
21
|
+
(min-selected)/remaining), then randomly picks within that category.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
people: People collection
|
|
25
|
+
features: Feature definitions with min/max targets
|
|
26
|
+
number_people_wanted: Number of people to select
|
|
27
|
+
check_same_address: Whether to remove household members when selecting someone
|
|
28
|
+
check_same_address_columns: Address columns for household identification
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (selected_committees, output_messages) where:
|
|
32
|
+
- selected_committees: List containing one frozenset of selected person IDs
|
|
33
|
+
- output_messages: List of log messages about the selection process
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
SelectionError: If selection becomes impossible (not enough people, etc.)
|
|
37
|
+
"""
|
|
38
|
+
output_lines = ["Using legacy algorithm."]
|
|
39
|
+
people_selected: set[str] = set()
|
|
40
|
+
|
|
41
|
+
# Create PeopleFeatures and initialize
|
|
42
|
+
people_features = PeopleFeatures(people, features, check_same_address, check_same_address_columns or [])
|
|
43
|
+
people_features.update_all_features_remaining()
|
|
44
|
+
people_features.prune_for_feature_max_0()
|
|
45
|
+
|
|
46
|
+
# Main selection loop
|
|
47
|
+
for count in range(number_people_wanted):
|
|
48
|
+
# Find the category with highest priority ratio
|
|
49
|
+
try:
|
|
50
|
+
ratio_result = people_features.find_max_ratio_category()
|
|
51
|
+
except errors.SelectionError as e:
|
|
52
|
+
msg = f"Selection failed on iteration {count + 1}: {e}"
|
|
53
|
+
raise errors.SelectionError(msg) from e
|
|
54
|
+
|
|
55
|
+
# Find the randomly selected person within that category
|
|
56
|
+
target_feature = ratio_result.feature_name
|
|
57
|
+
target_value = ratio_result.feature_value
|
|
58
|
+
random_position = ratio_result.random_person_index
|
|
59
|
+
|
|
60
|
+
selected_person_key = people_features.people.find_person_by_position_in_category(
|
|
61
|
+
target_feature, target_value, random_position
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Should never select the same person twice
|
|
65
|
+
assert selected_person_key not in people_selected, f"Person {selected_person_key} was already selected"
|
|
66
|
+
|
|
67
|
+
# Select the person (this also removes household members if configured)
|
|
68
|
+
people_selected.add(selected_person_key)
|
|
69
|
+
selected_person_data = people_features.people.get_person_dict(selected_person_key)
|
|
70
|
+
household_members_removed = people_features.select_person(selected_person_key)
|
|
71
|
+
|
|
72
|
+
# Add output messages about household member removal
|
|
73
|
+
if household_members_removed:
|
|
74
|
+
output_lines.append(
|
|
75
|
+
f"Selected {selected_person_key}, also removed household members: "
|
|
76
|
+
f"{', '.join(household_members_removed)}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Handle any categories that are now full after this selection
|
|
80
|
+
try:
|
|
81
|
+
category_messages = people_features.handle_category_full_deletions(selected_person_data)
|
|
82
|
+
output_lines.extend(category_messages)
|
|
83
|
+
except errors.SelectionError as e:
|
|
84
|
+
msg = f"Selection failed after selecting {selected_person_key}: {e}"
|
|
85
|
+
raise errors.SelectionError(msg) from e
|
|
86
|
+
|
|
87
|
+
# Check if we're about to run out of people (but not on the last iteration)
|
|
88
|
+
if count < (number_people_wanted - 1) and people_features.people.count == 0:
|
|
89
|
+
msg = "Selection failed: Ran out of people before completing selection"
|
|
90
|
+
raise errors.SelectionError(msg)
|
|
91
|
+
|
|
92
|
+
# Return in legacy format: list containing single frozenset
|
|
93
|
+
return [frozenset(people_selected)], output_lines
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import ItemsView, Iterable, Iterator
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sortition_algorithms import errors
|
|
6
|
+
from sortition_algorithms.features import FeatureCollection
|
|
7
|
+
from sortition_algorithms.settings import Settings
|
|
8
|
+
from sortition_algorithms.utils import StrippedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class People:
|
|
12
|
+
def __init__(self, columns_to_keep: list[str]) -> None:
|
|
13
|
+
self._columns_to_keep = columns_to_keep
|
|
14
|
+
self._full_data: dict[str, dict[str, str]] = {}
|
|
15
|
+
|
|
16
|
+
def __eq__(self, other: Any) -> bool:
|
|
17
|
+
if not isinstance(other, self.__class__):
|
|
18
|
+
return False
|
|
19
|
+
return self._full_data == other._full_data and self._columns_to_keep == self._columns_to_keep
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def count(self) -> int:
|
|
23
|
+
return len(self._full_data)
|
|
24
|
+
|
|
25
|
+
def __iter__(self) -> Iterator[str]:
|
|
26
|
+
return iter(self._full_data)
|
|
27
|
+
|
|
28
|
+
def items(self) -> ItemsView[str, dict[str, str]]:
|
|
29
|
+
return self._full_data.items()
|
|
30
|
+
|
|
31
|
+
def add(self, person_key: str, data: StrippedDict, features: FeatureCollection) -> None:
|
|
32
|
+
person_full_data: dict[str, str] = {}
|
|
33
|
+
# get the feature values: these are the most important and we must check them
|
|
34
|
+
for feature_name, feature_values in features.feature_values():
|
|
35
|
+
# check for input errors here - if it's not in the list of feature values...
|
|
36
|
+
# allow for some unclean data - at least strip empty space, but only if a str!
|
|
37
|
+
# (some values will can be numbers)
|
|
38
|
+
p_value = data[feature_name]
|
|
39
|
+
if p_value not in feature_values:
|
|
40
|
+
exc_msg = (
|
|
41
|
+
f"ERROR reading in people (read_in_people): "
|
|
42
|
+
f"Person (id = {person_key}) has value '{p_value}' not in feature {feature_name}"
|
|
43
|
+
)
|
|
44
|
+
raise errors.BadDataError(exc_msg)
|
|
45
|
+
person_full_data[feature_name] = p_value
|
|
46
|
+
# then get the other column values we need
|
|
47
|
+
# this is address, name etc that we need to keep for output file
|
|
48
|
+
# we don't check anything here - it's just for user convenience
|
|
49
|
+
for col in self._columns_to_keep:
|
|
50
|
+
person_full_data[col] = data[col]
|
|
51
|
+
|
|
52
|
+
# add all the data to our people object
|
|
53
|
+
self._full_data[person_key] = person_full_data
|
|
54
|
+
|
|
55
|
+
def remove(self, person_key: str) -> None:
|
|
56
|
+
del self._full_data[person_key]
|
|
57
|
+
|
|
58
|
+
def remove_many(self, person_keys: Iterable[str]) -> None:
|
|
59
|
+
for key in person_keys:
|
|
60
|
+
self.remove(key)
|
|
61
|
+
|
|
62
|
+
def get_person_dict(self, person_key: str) -> dict[str, str]:
|
|
63
|
+
return self._full_data[person_key]
|
|
64
|
+
|
|
65
|
+
def households(self, address_columns: list[str]) -> dict[tuple[str, ...], list[str]]:
|
|
66
|
+
"""
|
|
67
|
+
Generates a dict with:
|
|
68
|
+
- keys: a tuple containing the address strings
|
|
69
|
+
- values: a list of person_key for each person at that address
|
|
70
|
+
"""
|
|
71
|
+
households = defaultdict(list)
|
|
72
|
+
for person_key, person in self._full_data.items():
|
|
73
|
+
address = tuple(person[col] for col in address_columns)
|
|
74
|
+
households[address].append(person_key)
|
|
75
|
+
return households
|
|
76
|
+
|
|
77
|
+
def matching_address(self, person_key: str, address_columns: list[str]) -> Iterable[str]:
|
|
78
|
+
"""
|
|
79
|
+
Returns a list of person keys for all people who have an address matching
|
|
80
|
+
the address of the person passed in.
|
|
81
|
+
"""
|
|
82
|
+
person = self._full_data[person_key]
|
|
83
|
+
person_address = tuple(person[col] for col in address_columns)
|
|
84
|
+
for loop_key, loop_person in self._full_data.items():
|
|
85
|
+
if loop_key == person_key:
|
|
86
|
+
continue # skip the person we've been given
|
|
87
|
+
if person_address == tuple(loop_person[col] for col in address_columns):
|
|
88
|
+
yield loop_key
|
|
89
|
+
|
|
90
|
+
def find_person_by_position_in_category(self, feature_name: str, feature_value: str, position: int) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Find the nth person (1-indexed) in a specific feature category.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
feature_name: Name of the feature (e.g., "gender")
|
|
96
|
+
feature_value: Value of the feature (e.g., "male")
|
|
97
|
+
position: 1-indexed position within the category
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Person key of the person at the specified position
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
SelectionError: If no person is found at the specified position
|
|
104
|
+
"""
|
|
105
|
+
current_position = 0
|
|
106
|
+
|
|
107
|
+
for person_key, person_dict in self._full_data.items():
|
|
108
|
+
if person_dict[feature_name] == feature_value:
|
|
109
|
+
current_position += 1
|
|
110
|
+
if current_position == position:
|
|
111
|
+
return person_key
|
|
112
|
+
|
|
113
|
+
# Should always find someone if position is valid
|
|
114
|
+
msg = f"Failed to find person at position {position} in {feature_name}/{feature_value}"
|
|
115
|
+
raise errors.SelectionError(msg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# simple helper function to tidy the code below
|
|
119
|
+
def _check_columns_exist_or_multiple(people_head: list[str], column_list: Iterable[str], error_text: str) -> None:
|
|
120
|
+
for column in column_list:
|
|
121
|
+
column_count = people_head.count(column)
|
|
122
|
+
if column_count == 0:
|
|
123
|
+
msg = f"No '{column}' column {error_text} found in people data!"
|
|
124
|
+
raise errors.BadDataError(msg)
|
|
125
|
+
elif column_count > 1:
|
|
126
|
+
msg = f"MORE THAN 1 '{column}' column {error_text} found in people data!"
|
|
127
|
+
raise errors.BadDataError(msg)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_people_head(people_head: list[str], features: FeatureCollection, settings: Settings) -> None:
|
|
131
|
+
# check that id_column and all the features, columns_to_keep and
|
|
132
|
+
# check_same_address_columns are in the people data fields...
|
|
133
|
+
# check both for existence and duplicate column names
|
|
134
|
+
_check_columns_exist_or_multiple(people_head, [settings.id_column], "(unique id)")
|
|
135
|
+
_check_columns_exist_or_multiple(people_head, features.feature_names, "(a feature)")
|
|
136
|
+
_check_columns_exist_or_multiple(people_head, settings.columns_to_keep, "(to keep)")
|
|
137
|
+
_check_columns_exist_or_multiple(
|
|
138
|
+
people_head,
|
|
139
|
+
settings.check_same_address_columns,
|
|
140
|
+
"(to check same address)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_settings_keep_address_columns(settings: Settings) -> None:
|
|
145
|
+
# let's just merge the check_same_address_columns into columns_to_keep in case they aren't in both
|
|
146
|
+
# TODO: review this - should we do this in settings rather than here?
|
|
147
|
+
for col in settings.check_same_address_columns:
|
|
148
|
+
if col not in settings.columns_to_keep:
|
|
149
|
+
settings.columns_to_keep.append(col)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_in_people(
|
|
153
|
+
people_head: list[str],
|
|
154
|
+
people_body: Iterable[dict[str, str]],
|
|
155
|
+
features: FeatureCollection,
|
|
156
|
+
settings: Settings,
|
|
157
|
+
) -> tuple[People, list[str]]:
|
|
158
|
+
all_msg: list[str] = []
|
|
159
|
+
_check_people_head(people_head, features, settings)
|
|
160
|
+
_ensure_settings_keep_address_columns(settings)
|
|
161
|
+
people = People(settings.columns_to_keep)
|
|
162
|
+
for index, row in enumerate(people_body):
|
|
163
|
+
stripped_row = StrippedDict(row)
|
|
164
|
+
pkey = stripped_row[settings.id_column]
|
|
165
|
+
# skip over any blank lines... but warn the user
|
|
166
|
+
if pkey == "":
|
|
167
|
+
all_msg.append(f"<b>WARNING</b>: blank cell found in ID column in row {index} - skipped that line!")
|
|
168
|
+
continue
|
|
169
|
+
people.add(pkey, stripped_row, features)
|
|
170
|
+
# TODO: should this be done outside this function?
|
|
171
|
+
# so this function just reads in people but doesn't update the features stuff
|
|
172
|
+
# people_features = PeopleFeatures(people, features)
|
|
173
|
+
# people_features.update_all_features_remaining()
|
|
174
|
+
# people_features.prune_for_feature_max_0()
|
|
175
|
+
return people, all_msg
|