diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 523d18d..f08aae9 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -92,12 +92,11 @@ def algorithm_dream( for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - if not order_diverse_dict: - raise Exception( - "Error: One diversification field required!", - "You have to set at least one field that is used to " - "diversify people across groups.", - ) + # No diversification field is required: with `order_diverse_dict` + # empty, `cats_diverse` is empty throughout the swap machinery below, + # so every table is reported as perfectly balanced and swaps are + # chosen on unique-meeting count alone -- DREAM then behaves as a + # meeting-optimisation-only allocator. if len(order_cluster_dict) > 1: raise Exception( diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 05e089f..fa7651a 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -106,12 +106,10 @@ def algorithm_hermes( for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - if not order_diverse_dict: - raise Exception( - "Error: One diversification field required!", - "You have to set at least one field that is used to " - "diversify people across groups.", - ) + # No diversification field is required: with `order_diverse_dict` + # empty, `cats_diverse` is empty throughout the swap machinery below + # (see `pareto_swaps` in this module), so HERMES then behaves as a + # meeting-optimisation-only allocator, same as DREAM in that case. if len(order_cluster_dict) > 1: raise Exception( @@ -380,12 +378,19 @@ def pareto_swaps( # Use the highest configured probability among the diversity # fields the chosen candidate actually pareto-improves (falling # back to the overall maximum if it improves none, e.g. it was - # accepted purely for its meeting-uniqueness score). + # accepted purely for its meeting-uniqueness score). With no + # diversity fields configured at all, `pareto_probs` is empty and + # no candidate ever pareto-improves a field, so fall back to 0.0 + # instead of `max()`-ing an empty sequence -- `select_key` then + # always takes its meeting-uniqueness branch, matching DREAM's + # own degenerate no-diversify-field behaviour. def pareto_prob_for(k): relevant = [ pareto_probs[field_id] for field_id in demog_scores.get(k, ()) ] - return max(relevant) if relevant else max(pareto_probs.values()) + if relevant: + return max(relevant) + return max(pareto_probs.values()) if pareto_probs else 0.0 final_swap = select_key( final_candidates, final_meetings, pareto_prob_for, random diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index fe4216e..b6a6188 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -3,6 +3,8 @@ import warnings from typing import Callable, Iterable, Hashable +import numpy as np + from groupselect import FieldMode, Algorithm, allocate_numpy, AllocatorResult @@ -57,7 +59,16 @@ def allocate_pandas( participants_codes = participants[list(fields.keys())].apply( lambda col: col.cat.codes ) - participants_numpy = participants_codes.to_numpy() + if fields: + participants_numpy = participants_codes.to_numpy() + else: + # `.apply()` on a zero-column selection returns an empty + # DataFrame with no columns to infer a dtype from, so pandas + # defaults it to float64 -- `to_numpy()` would then fail + # `allocate_numpy`'s integer-dtype check below. Build the + # (n_participants, 0) array directly instead, with an explicit + # integer dtype. + participants_numpy = np.empty((len(participants_codes), 0), dtype=int) fields_numpy = { participants_codes.columns.tolist().index(k): v for k, v in fields.items() diff --git a/tests/test_pandas.py b/tests/test_pandas.py index e443344..f9cc4a9 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -29,3 +29,66 @@ def test_philipps_example_data(): "display.max_rows", None, "display.max_columns", None ): print(res) + + +@pytest.mark.parametrize("algorithm", ["DREAM", "HERMES"]) +def test_dream_hermes_without_diversify_field(algorithm): + """DREAM/HERMES must allocate even with no diversify field defined. + + Unlike LEGACY (see ``test_legacy_requires_diversify_field`` below), + DREAM and HERMES do not need a diversify field: their swap machinery + degrades to optimising only for unique meetings when no diversity + fields are configured -- see `algorithm_dream.py`/`algorithm_hermes.py` + for how this is handled. + """ + df = example_data_pd["default"] + settings = {"pareto_prob": 0.4} if algorithm == "DREAM" else {} + res = df.groupselect.allocate( + fields={"photo consent": "cluster"}, + n_part_per_group=6, + algorithm=algorithm, + settings=settings, + ) + assert len(res) == len(df) + + +def test_legacy_requires_diversify_field(): + """LEGACY, unlike DREAM/HERMES, still requires a diversify field.""" + df = example_data_pd["default"] + with pytest.raises(Exception, match="diversification field required"): + df.groupselect.allocate( + fields={"photo consent": "cluster"}, + n_part_per_group=3 * [6], + algorithm="Legacy", + ) + + +@pytest.mark.parametrize("algorithm", ["DREAM", "HERMES"]) +def test_dream_hermes_with_no_fields_at_all(algorithm): + """DREAM/HERMES must allocate with an entirely empty `fields` dict. + + `participants[[]].apply(...)` returns a zero-column DataFrame that + pandas defaults to dtype float64 (there are no columns to infer a + dtype from), which used to fail `allocate_numpy`'s integer-dtype + check -- see `allocate_pandas()`'s explicit `fields`-empty branch. + """ + df = example_data_pd["default"] + settings = {"pareto_prob": 0.4} if algorithm == "DREAM" else {} + res = df.groupselect.allocate( + fields={}, + n_part_per_group=6, + algorithm=algorithm, + settings=settings, + ) + assert len(res) == len(df) + + +def test_legacy_requires_diversify_field_with_no_fields_at_all(): + """LEGACY must still reject an entirely empty `fields` dict too.""" + df = example_data_pd["default"] + with pytest.raises(Exception, match="diversification field required"): + df.groupselect.allocate( + fields={}, + n_part_per_group=6, + algorithm="Legacy", + )