From 21f0d2015eb584a462f9075f83e171fb1f3f7696 Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Mon, 8 Dec 2025 13:27:48 +0000 Subject: [PATCH 01/26] added dream --- groupselect/algorithms/__init__.py | 1 + groupselect/algorithms/algorithm_dream.py | 686 ++++++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 groupselect/algorithms/algorithm_dream.py diff --git a/groupselect/algorithms/__init__.py b/groupselect/algorithms/__init__.py index 0507eec..a9f788a 100644 --- a/groupselect/algorithms/__init__.py +++ b/groupselect/algorithms/__init__.py @@ -8,6 +8,7 @@ @unique class Algorithm(Enum): Legacy = 0 + dream = 1 # Heuristic = 1 # TODO: Add heuristic algorithm # Define functions of algorithms. diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py new file mode 100644 index 0000000..84d6776 --- /dev/null +++ b/groupselect/algorithms/algorithm_dream.py @@ -0,0 +1,686 @@ + +import copy +import math +from itertools import product +from typing import Callable + +import numpy as np + + +from groupselect.field_mode import FieldMode +from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult + + +def algorithm_dream(participants: np.ndarray[int], + fields: dict[int, FieldMode], + groups: list[(int, int)], + manuals: dict[int, int], + progress_func: None | Callable = None, + n_attempts: int = 3, + seed: None | int = None): + + nallocations = len(groups) + progress_bar = progress_func + tables = groups[0][0] + for value in groups: + tables = value[0] + seats = value + + order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] + + if len(order_cluster ) >=1: + val_cluster = 'cluster' + else: + val_cluster = '' + + X = participants[:, order_cluster] + X = np.unique(X) + + Y = participants[:, order_diverse] + Y = np.unique(Y) + + order_cluster_dict = dict(zip(order_cluster, [list(X)])) + order_diverse_dict = dict(zip(order_diverse, [list(Y)])) + + swap_rounds = 1 + + cluster_tables = 2 + + m_data = participants.shape[0] + + pareto_prob = 0.5 + + + ''' + cluster_tables: int, + m_data: int, + pareto_prob: float, + swap_rounds: int, + + progress_bar: any == None''' + + seats = math.ceil(m_data /tables) + previous_meetings = {} + + try: + random = np.random.default_rng(seed) + except: + raise Exception("Error: Random seed incorrect!", "There was a problem setting the random seed. Please check your input!") + + if (nallocations < 1): + raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") + + # if len(tables)>1: do this bit + + peopledata_vals_used = [{} for i in range(m_data)] + + for i in range(m_data): + for j in order_cluster + order_diverse: + peopledata_vals_used[i][j] = int(participants[i][j]) + + # order_cluster_dict = get_field_cluster_dict() + # order_diverse_dict = get_field_diverse_dict() + + + 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.") + + if len(order_cluster_dict ) >1: + raise Exception("Error: Only one cluster field permitted. Please reduce the number of cluster fields.") + + no_cluster_agents = 0 + + + if len(order_cluster_dict ) ==1: + cluster_key = next(iter(order_cluster_dict)) + no_cluster_agents = sum(1 for person in peopledata_vals_used if person[cluster_key] == val_cluster) + + + n_swap_loops = int(swap_rounds) + if n_swap_loops < 1: + raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") + + n_results = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_prob, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random) + + allocation_results = n_results + ''' allocations = [] + for result in n_results[0]: + allocations.append(n_results[0][result]) + + allocation_group_outcome = allocations + + d_mult = m_data// (tables**2) + L_R = ((tables**2) * 8.5 * d_mult * (d_mult-1)) + d_mult * (m_data % (tables**2)) + min_duplicates = max(0, L_R) + + optimal_pairs = 0 + for table in allocations[0]: + n = len(table) + optimal_pairs += n * (n - 1) // 2 + + total_possible_pairs = 0 + for round_no in range(nallocations): + if round_no == 0: + # no restrictions on repeating pairs + total_possible_pairs += optimal_pairs + else: + total_possible_pairs += optimal_pairs - min_duplicates + # calculate total pairs in sample + total_pairs = m_data * (m_data - 1) // 2 + + if 0 in n_results[1][nallocations - 1]: + allocation_group_links_pp = (total_pairs - n_results[2][nallocations - 1][0]) / m_data + else: + # all pairs have met + allocation_group_links_pp = total_pairs + + # maximum links from round 0 to 1 are a function of table size and number of tables + allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data + + ''' + + # Select the sample with maximum number of meetings. + # final_results1: list[AllocationEnsemble] = [ + # AllocationEnsemble() + # for _ in range(1) + # ] + + + final_results2 = max(allocation_results) + + return AllocatorResult(final_results2) + +def allocate(tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_prob, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random): + n_rounds = nallocations + pre_meeting_dist = {} + post_meeting_dist = {} + new_meetings_in_round = {} + pre_balance = {} + post_balance = {} + + allocations_list = {} + + for i in range(m_data): + for j in range( i +1, m_data): + pair = (i ,j) + if pair not in previous_meetings: + previous_meetings[pair] = 0 + + # allocation_attempts : AllocationEnsemble = AllocationEnsemble() + allocation_attempts: list[AllocationEnsemble] = [ + AllocationEnsemble() + for _ in range(1) + ] + + + for round_no in range(n_rounds): + if progress_bar: progress_bar(round_no +1) + + + if not(isinstance(tables, int)): + no_tables = tables[round_no] + else: + no_tables = tables + + no_larger_tables = m_data % no_tables + + seats = math.ceil(m_data / no_tables) + + min_cluster_tables = math.ceil(no_cluster_agents /seats) + + n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) + no_smaller_tables = no_tables - no_larger_tables + + if no_larger_tables == 0: + template = [[None for s in range(seats)] for r in range(no_smaller_tables)] + else: + template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ + [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] + + meetings_previous_round = previous_meetings.copy() + + '''round_assign_pre, round_assign_swap, meetings_pre, ''' + allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, + order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, + pareto_prob, random) + + allocation = Allocation( + ParticipantGroup(p_id for p_id in group) + for group in allocation + ) + for n, ensemble in enumerate(allocation_attempts): + ensemble.append(allocation) + + '''pre_occurences = {} + for value in meetings_pre.values(): + pre_occurences[value] = pre_occurences.get(value, 0) + 1 + pre_meeting_dist[round_no] = pre_occurences + occurences = {} + for value in previous_meetings.values(): + occurences[value] = occurences.get(value, 0) + 1 + post_meeting_dist[round_no] = occurences + + new_meetings = {} + for pair in previous_meetings: + if previous_meetings[pair]-meetings_previous_round[pair] == 1: + new_meetings[pair] = previous_meetings[pair] + round_meetings = {} + for value in new_meetings.values(): + round_meetings[value] = round_meetings.get(value, 0) + 1 + new_meetings_in_round[round_no] = round_meetings + + pre_demog_evaluations = {} + for index, table in enumerate(round_assign_pre): + pre_demog_evaluations[index] = {} + pre_demog_evaluations[index] = evaluate_demographics(round_assign_pre, index, peopledata_vals_used, order_diverse_dict, m_data)[0] + pre_balance[round_no] = averages_from_evals(pre_demog_evaluations) + post_demog_evaluations = {} + for index, table in enumerate(round_assign_swap): + post_demog_evaluations[index] = {} + post_demog_evaluations[index] = evaluate_demographics(round_assign_swap, index, peopledata_vals_used, order_diverse_dict, m_data)[0] + post_balance[round_no] = averages_from_evals(post_demog_evaluations) + + allocations_list[round_no] = round_assign_swap + + allocations_list, pre_meeting_dist, post_meeting_dist, new_meetings_in_round, pre_balance, post_balance, + ''' + + return allocation_attempts + + +def calculate_ideal_balance(cats_diverse, + m_data, + people): + ideal_balance = {} + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for row in people: + for i, category in enumerate(cats_diverse[demog]): + if row[demog] == category: + counts[i] += 1 + ideal_balance[demog] = [count / m_data for count in counts] + return ideal_balance + + +def averages_from_evals(evaluations: dict): + values_per_key = {} + + for nested_dict in evaluations.values(): + for key, value in nested_dict.items(): + if key not in values_per_key: + values_per_key[key] = [] + values_per_key[key].append(value) + averages = {} + for key in values_per_key: + lst = values_per_key[key] + averages[key] = sum(lst) / len(lst) + return averages + + +def run_round(template, + n_swap_loops, + seats, + m_data, + manual_pids, + n_cluster_tables, + cats_cluster, + cats_diverse, + people, + val_cluster, + no_tables, + previous_meetings, + pareto_prob, + random) -> Allocation: + allocations = copy.deepcopy(template) + + all_pids = list(range(m_data)) + + shuffled_pids = all_pids.copy() + + random.shuffle(shuffled_pids) + + shuffled_pids = [x for x in shuffled_pids if x not in manual_pids] + + cluster_table_index = list(range(n_cluster_tables)) + + if len(cats_cluster) == 1: + cluster_individuals = [] + for index, person in enumerate(people): + if person[next(iter(cats_cluster))] == val_cluster: + cluster_individuals.append(index) + cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] + + chosen_chair = 0 + + total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) + + if len(cluster_individuals) > total_clustering_spaces: + raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") + for agent in cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % len(cluster_table_index) + seat_no = math.floor( + chosen_chair / len(cluster_table_index) % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 + else: + cluster_individuals = [] + + non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] + chosen_chair = 0 + + for agent in non_cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % no_tables + seat_no = math.floor(chosen_chair / no_tables % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 + + if n_swap_loops == 1: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + else: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + for swap_round in range(1, n_swap_loops): + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, + pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, + m_data, pareto_prob, random) + + raw_meetings = previous_meetings.copy() + + for sublist in pareto_allocations: + + for i in range(len(sublist)): + + for j in range(i + 1, len(sublist)): + pair = (min(sublist[i], sublist[j]), + max(sublist[i], sublist[j])) + + previous_meetings[pair] += 1 + + for sublist in allocations: + for i in range(len(sublist)): + for j in range(i + 1, len(sublist)): + pair = (min(sublist[i], sublist[j]), + max(sublist[i], sublist[j])) + # Increment count for the pair in the dictionary + raw_meetings[pair] += 1 + + this_alloc = Allocation( + ParticipantGroup(list) + for list in pareto_allocations + ) + + '''allocations, pareto_allocations, raw_meetings, ''' + + return this_alloc + + +def pareto_swaps(shuffled_pids, + cluster_individuals, + cluster_table_index, + temp_allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random): + temp_allocations_update = temp_allocations.copy() + + table_meeting_evaluations = {} + table_demog_evaluations = {} + for index, table in enumerate(temp_allocations_update): + table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) + table_demog_evaluations[index] = {} + table_demog_evaluations[index] = evaluate_demographics( + temp_allocations_update, index, people, cats_diverse, m_data) + + for pid in shuffled_pids: + for index, table in enumerate(temp_allocations_update): + if pid in table: + table_no = index + + pid_info = {key: people[pid][key] + for key in people[pid] if key in cats_diverse} + + candidate_demogs = {} + + for demog in cats_diverse: + candidate_demogs[demog] = table_demog_evaluations[table_no][1][demog][pid_info[demog]] + + candidate_profiles = generate_combinations(candidate_demogs, pid_info) + candidate_swaps = {} + + for profile in candidate_profiles: + if pid in cluster_individuals: + candidate_swap_tables = [x for x in table_demog_evaluations if ( + x != table_no) and (x in cluster_table_index)] + else: + candidate_swap_tables = [ + x for x in table_demog_evaluations if x != table_no] + for candidate_table in candidate_swap_tables: + pareto_score = 0 + pareto_profile = table_demog_evaluations[candidate_table][1] + table_valid = True + for index, demog in enumerate(pareto_profile): + if pid_info[demog] in pareto_profile[demog][profile[index]]: + pareto_score += 1 + elif pid_info[demog] != profile[index]: + table_valid = False + break + if table_valid: + if pid in cluster_individuals: + for swap_pid in temp_allocations_update[candidate_table]: + if swap_pid not in manual_pids: + if tuple(people[swap_pid][key] for key in people[swap_pid] if + key in cats_diverse) == profile: + candidate_swaps[swap_pid] = pareto_score + \ + candidate_profiles[profile] + else: + for swap_pid in temp_allocations_update[candidate_table]: + if swap_pid not in cluster_individuals: + if swap_pid not in manual_pids: + if tuple(people[swap_pid][key] for key in people[swap_pid] if + key in cats_diverse) == profile: + candidate_swaps[swap_pid] = pareto_score + \ + candidate_profiles[profile] + + if len(candidate_swaps) == 0: + continue + + candidate_meetings = {} + for swap in candidate_swaps: + candidate_meetings[swap] = evaluate_swap(pid, swap, temp_allocations_update, table_meeting_evaluations, + previous_meetings) + + candidate_swaps = {key: value for key, value in candidate_swaps.items() if ( + candidate_swaps[key] > 0) or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0)} + if len(candidate_swaps) == 0: + continue + + distinct_candidates = {} + for distinct_value in {value for value in candidate_swaps.values()}: + distinct_keys = { + key for key, value in candidate_swaps.items() if value == distinct_value} + max_meetings = max( + value for key, value in candidate_meetings.items() if key in distinct_keys) + distinct_candidates.update({key: value for key, value in candidate_swaps.items( + ) if (value == distinct_value) and (candidate_meetings[key] == max_meetings)}) + distinct_meetings = {key: value for key, value in candidate_meetings.items( + ) if key in distinct_candidates} + + reverse_mapping = {} + for key, value in distinct_candidates.items(): + if value not in reverse_mapping: + reverse_mapping[value] = [] + reverse_mapping[value].append(key) + + final_candidates = {} + for value, keys in reverse_mapping.items(): + final_candidates[random.choice(keys)] = value + final_meetings = { + key: value for key, value in distinct_meetings.items() if key in final_candidates} + + keys_to_remove = set() + + for key in final_meetings.keys(): + if any(final_meetings[other_key] >= final_meetings[key] and final_candidates[other_key] > final_candidates[ + key] for other_key in final_meetings.keys() if other_key != key): + keys_to_remove.add(key) + for key in keys_to_remove: + del final_meetings[key] + del final_candidates[key] + + final_swap = select_key(final_candidates, final_meetings, pareto_prob, random) + if final_swap == None: + continue + + for index, table in enumerate(temp_allocations_update): + if final_swap in table: + swap_table = index + + temp_allocations_update[table_no] = [ + final_swap if x == pid else x for x in temp_allocations_update[table_no]] + temp_allocations_update[swap_table] = [ + pid if x == final_swap else x for x in temp_allocations_update[swap_table]] + + for index in [table_no, swap_table]: + table_meeting_evaluations[index] = evaluate_meetings( + temp_allocations_update[index], previous_meetings) + table_demog_evaluations[index] = {} + table_demog_evaluations[index] = evaluate_demographics( + temp_allocations_update, index, people, cats_diverse, m_data) + + return temp_allocations_update + + +def select_key(pareto, + meet, + pareto_prob, + random): + pareto_copy = pareto.copy() + meet_copy = meet.copy() + total_a = sum(pareto_copy.values()) + + if random.random() < pareto_prob: + if len(pareto_copy) == 1: + return next(iter(pareto_copy.keys())) + + cumulative_prob_a = {} + cumulative_sum = 0 + for key, value in pareto_copy.items(): + cumulative_sum += value / total_a + cumulative_prob_a[key] = cumulative_sum + + rand_num = random.random() + for key, prob in cumulative_prob_a.items(): + if rand_num <= prob: + return key + else: + meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} + if len(meet_copy) == 0: + return None + if len(meet_copy) == 1: + return next(iter(meet_copy.keys())) + + total_b = sum(meet_copy.values()) + cumulative_prob_b = {} + cumulative_sum = 0 + for key, value in meet_copy.items(): + cumulative_sum += value / total_b + cumulative_prob_b[key] = cumulative_sum + rand_num = random.random() + + for key, prob in cumulative_prob_b.items(): + if rand_num <= prob: + return key + + +def evaluate_swap(original_id, + swap_id, + allocations, + table_meeting_evaluations, + previous_meetings): + for index, table in enumerate(allocations): + if swap_id in table: + swap_table = index + if original_id in table: + table_no = index + + original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( + x for x in table_meeting_evaluations[swap_table].values()) + original_table = allocations[table_no] + swap_table = allocations[swap_table] + original_table_2 = [swap_id if x == original_id else x for x in original_table] + swap_table_2 = [original_id if x == swap_id else x for x in swap_table] + meetings_1 = evaluate_meetings(original_table_2, previous_meetings) + meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) + + new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) + + return original_meetings - new_meetings + + +def generate_combinations(demogs, + info): + demographics = list(demogs.keys()) + + combinations_count = {} + + for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): + combination = tuple(values) + count = len(demogs) - \ + sum(1 for v in combination if v in info.values()) + combinations_count[combination] = count + return combinations_count + + +def evaluate_meetings(table, + previous_meetings): + total_meetings = {} + for i in range(len(table)): + for j in range(i + 1, len(table)): + agent1, agent2 = min(table[i], table[j]), max( + table[i], table[j]) + # Sum the values from pairs_dict for the pair of agents + total_meetings[agent1] = total_meetings.get( + agent1, 0) + previous_meetings.get((agent1, agent2), 0) + total_meetings[agent2] = total_meetings.get( + agent2, 0) + previous_meetings.get((agent1, agent2), 0) + return (total_meetings) + + +def evaluate_demographics(temp_allocations, + table_no, + people, + cats_diverse, + m_data): + table = temp_allocations[table_no] + + table_data = {} + for index in table: + table_data[index] = people[index] + + ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) + + table_balance = {} + table_actions = {} + table_distances = {} + table_length = len(table) + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for person in table_data.values(): + for i, category in enumerate(cats_diverse[demog]): + if person.get(demog) == category: + counts[i] += 1 + table_balance[demog] = [count / table_length for count in counts] + + table_distances[demog] = sum([abs(x - y) for x, y in zip( + ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) + + table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], + len(table)) + return table_distances, table_actions + + +def evaluate_actions(ideal_dist, + table_dist, + cat_labels, + table_size): + table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] + actions = {} + + for index, label in enumerate(cat_labels): + actions_for_label = [] + if table_dist[index] > ideal_dist[index]: + for a, b in zip(table_discrepancies, cat_labels): + if a < 0: + actions_for_label.append(b) + actions[label] = actions_for_label + return actions + + From f488b9e0f11c4c2d4cdb0bbfb083fa10dced9696 Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Wed, 11 Feb 2026 19:48:21 +0000 Subject: [PATCH 02/26] updated_algorithm --- groupselect/algorithms/algorithm_dream.py | 31 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 84d6776..5f1307e 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -19,6 +19,7 @@ def algorithm_dream(participants: np.ndarray[int], n_attempts: int = 3, seed: None | int = None): + print("dream") nallocations = len(groups) progress_bar = progress_func tables = groups[0][0] @@ -27,7 +28,8 @@ def algorithm_dream(participants: np.ndarray[int], seats = value order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + if len(order_cluster ) >=1: val_cluster = 'cluster' @@ -38,10 +40,20 @@ def algorithm_dream(participants: np.ndarray[int], X = np.unique(X) Y = participants[:, order_diverse] - Y = np.unique(Y) + + lister = [None] * len(order_diverse) + + for i in range(0, len(order_diverse)): + + lister[i] =[int(item[i]) for item in Y] + + lister[i] = np.unique(lister[i]) + lister[i] = lister[i].tolist() + order_cluster_dict = dict(zip(order_cluster, [list(X)])) - order_diverse_dict = dict(zip(order_diverse, [list(Y)])) + order_diverse_dict = dict(zip(order_diverse, lister)) + swap_rounds = 1 @@ -267,13 +279,17 @@ def calculate_ideal_balance(cats_diverse, m_data, people): ideal_balance = {} + + i = 0 for demog in cats_diverse: + i += 1 counts = [0] * len(cats_diverse[demog]) for row in people: for i, category in enumerate(cats_diverse[demog]): if row[demog] == category: counts[i] += 1 ideal_balance[demog] = [count / m_data for count in counts] + return ideal_balance @@ -318,6 +334,7 @@ def run_round(template, cluster_table_index = list(range(n_cluster_tables)) + if len(cats_cluster) == 1: cluster_individuals = [] for index, person in enumerate(people): @@ -411,6 +428,7 @@ def pareto_swaps(shuffled_pids, random): temp_allocations_update = temp_allocations.copy() + table_meeting_evaluations = {} table_demog_evaluations = {} for index, table in enumerate(temp_allocations_update): @@ -447,6 +465,7 @@ def pareto_swaps(shuffled_pids, pareto_profile = table_demog_evaluations[candidate_table][1] table_valid = True for index, demog in enumerate(pareto_profile): + if pid_info[demog] in pareto_profile[demog][profile[index]]: pareto_score += 1 elif pid_info[demog] != profile[index]: @@ -621,6 +640,7 @@ def generate_combinations(demogs, def evaluate_meetings(table, previous_meetings): + total_meetings = {} for i in range(len(table)): for j in range(i + 1, len(table)): @@ -631,6 +651,7 @@ def evaluate_meetings(table, agent1, 0) + previous_meetings.get((agent1, agent2), 0) total_meetings[agent2] = total_meetings.get( agent2, 0) + previous_meetings.get((agent1, agent2), 0) + return (total_meetings) @@ -639,8 +660,11 @@ def evaluate_demographics(temp_allocations, people, cats_diverse, m_data): + + table = temp_allocations[table_no] + table_data = {} for index in table: table_data[index] = people[index] @@ -664,6 +688,7 @@ def evaluate_demographics(temp_allocations, table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], len(table)) + return table_distances, table_actions From 13c0777a3ba907c94845ddda725f02e094514c08 Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Wed, 11 Feb 2026 20:19:28 +0000 Subject: [PATCH 03/26] updated_code --- groupselect/algorithms/algorithm_heuristic.py | 922 ++++++++++++++++++ groupselect/algorithms/algorithm_legacy.py | 185 +++- 2 files changed, 1104 insertions(+), 3 deletions(-) create mode 100644 groupselect/algorithms/algorithm_heuristic.py diff --git a/groupselect/algorithms/algorithm_heuristic.py b/groupselect/algorithms/algorithm_heuristic.py new file mode 100644 index 0000000..860194f --- /dev/null +++ b/groupselect/algorithms/algorithm_heuristic.py @@ -0,0 +1,922 @@ + +import copy +import math +import statistics +from itertools import product +from typing import Callable +import csv + +import numpy as np + + +from groupselect.field_mode import FieldMode +from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult + + +def algorithm_heuristic(participants: np.ndarray[int], + fields: dict[int, FieldMode], + groups: list[(int, int)], + manuals: dict[int, int], + progress_func: None | Callable = None, + n_attempts: int = 3, + seed: None | int = None): + + + nallocations = len(groups) + progress_bar = progress_func + tables = groups[0][0] + for value in groups: + tables = value[0] + seats = value + + + rank1 = False + rank2 = False + rank3 = False + order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + + for k in order_diverse: + + if fields[k] == FieldMode.Diversify_1: + rank1 = True + elif fields[k] == FieldMode.Diversify_2: + rank2 = True + else: + rank3 = True + if rank1 == True: + pareto_prob = 0.5 + elif rank2 == True: + pareto_prob = 0.275 + elif rank3 == True: + pareto_prob = 0.2 + + + + if len(order_cluster ) >=1: + val_cluster = 'cluster' + else: + val_cluster = '' + + X = participants[:, order_cluster] + + X = np.unique(X) + + Y = participants[:, order_diverse] + lister = [None] * len(order_diverse) + + + for i in range(0, len(order_diverse)): + + + lister[i] = [int(item[i]) for item in Y] + + lister[i] = np.unique(lister[i]) + lister[i] = lister[i].tolist() + + + + + + Y = np.unique(Y) + + + + order_cluster_dict = dict(zip(order_cluster, [list(X)])) + order_diverse_dict = dict(zip(order_diverse, lister)) + + + + swap_rounds = 1 + + cluster_tables = 2 + + m_data = participants.shape[0] + + + + ''' + cluster_tables: int, + m_data: int, + pareto_prob: float, + swap_rounds: int, + + progress_bar: any == None''' + + seats = math.ceil(m_data /tables) + previous_meetings = {} + + try: + random = np.random.default_rng(seed) + except: + raise Exception("Error: Random seed incorrect!", "There was a problem setting the random seed. Please check your input!") + + if (nallocations < 1): + raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") + + # if len(tables)>1: do this bit + + peopledata_vals_used = [{} for i in range(m_data)] + + for i in range(m_data): + for j in order_cluster + order_diverse: + peopledata_vals_used[i][j] = int(participants[i][j]) + + # order_cluster_dict = get_field_cluster_dict() + # order_diverse_dict = get_field_diverse_dict() + + + 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.") + + if len(order_cluster_dict ) >1: + raise Exception("Error: Only one cluster field permitted. Please reduce the number of cluster fields.") + + no_cluster_agents = 0 + + + if len(order_cluster_dict ) ==1: + cluster_key = next(iter(order_cluster_dict)) + no_cluster_agents = sum(1 for person in peopledata_vals_used if person[cluster_key] == val_cluster) + + + n_swap_loops = int(swap_rounds) + if n_swap_loops < 1: + raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") + + n_results, meet0, meet1, meet2, meet3= allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_prob, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) + + allocation_results = n_results + ''' allocations = [] + for result in n_results[0]: + allocations.append(n_results[0][result]) + + allocation_group_outcome = allocations + + d_mult = m_data// (tables**2) + L_R = ((tables**2) * 8.5 * d_mult * (d_mult-1)) + d_mult * (m_data % (tables**2)) + min_duplicates = max(0, L_R) + + optimal_pairs = 0 + for table in allocations[0]: + n = len(table) + optimal_pairs += n * (n - 1) // 2 + + total_possible_pairs = 0 + for round_no in range(nallocations): + if round_no == 0: + # no restrictions on repeating pairs + total_possible_pairs += optimal_pairs + else: + total_possible_pairs += optimal_pairs - min_duplicates + # calculate total pairs in sample + total_pairs = m_data * (m_data - 1) // 2 + + if 0 in n_results[1][nallocations - 1]: + allocation_group_links_pp = (total_pairs - n_results[2][nallocations - 1][0]) / m_data + else: + # all pairs have met + allocation_group_links_pp = total_pairs + + # maximum links from round 0 to 1 are a function of table size and number of tables + allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data + + ''' + + # Select the sample with maximum number of meetings. + # final_results1: list[AllocationEnsemble] = [ + # AllocationEnsemble() + # for _ in range(1) + # ] + distance = {} + + #for i in range (0, tables): + # distance[i] = evaluate_demographics(i, peopledata_vals_used, order_diverse_dict, m_data, fields) + + #distances = evaluate_demographics() + #print(distances, "distances") + + + print(allocation_results[0][0]) + final = [0] * nallocations + div_mean = [0] * nallocations + minimum = [0] * nallocations + maximum = [0] * nallocations + final_results2 = max(allocation_results) + for j in range(nallocations): + for i in range (0, tables): + distance[i] = evaluate_demographics(allocation_results[0][j], i, peopledata_vals_used, order_diverse_dict, m_data, fields) + this = [y for x,y in distance.items()] + this2 = [x for x,y in this] + this3 = [x[8] for x in this2] + #for i in range (0, len(distance)): + #if (j == 983): + #print(this3, "this3") + final[j] = statistics.mean(this3) + maximum[j] = max(this3) + minimum[j] = min(this3) + #print(final[0:j+1]) + div_mean[j] = statistics.mean(final[0:j+1]) + + #print(final[j]) + #print(maximum[j]) + #print(minimum[j]) + #meet_mean = statistics.mean(prev_meet_mean) + if pareto_prob == 0.275: + rank = 2 + elif pareto_prob == 0.5: + rank = 1 + else: + rank = 3 + + filename = ('meetsvsdiv(alloc =' + nallocations.__str__() + ', rank =' + rank.__str__() + ') urb.csv') + with open(filename, 'w', newline='') as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["round div_mean","total_div_mean", "meet0", "meet1", "meet2", "meet3"]) + for j in range(nallocations): + #writer.writerow([1,2,3,4]) + writer.writerow([final[j], div_mean[j], meet0[j], meet1[j], meet2[j], meet3[j]]) + + return AllocatorResult(final_results2) + +def allocate(tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_prob, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random, + fields): + n_rounds = nallocations + + previous_meetings_max = [0] * nallocations + previous_meetings_min = [0] * nallocations + round_meetings_mean = [0] * nallocations + total_meetings_mean = [0] * nallocations + + meet0 = [0] * nallocations + meet1 = [0] * nallocations + meet2 = [0] * nallocations + meet3 = [0] * nallocations + meet4 = [0] * nallocations + meet5 = [0] * nallocations + meet6 = [0] * nallocations + meet7 = [0] * nallocations + meet8 = [0] * nallocations + meet9 = [0] * nallocations + meet10 = [0] * nallocations + pre_meeting_dist = {} + post_meeting_dist = {} + new_meetings_in_round = {} + pre_balance = {} + post_balance = {} + + allocations_list = {} + + for i in range(m_data): + for j in range( i +1, m_data): + pair = (i ,j) + if pair not in previous_meetings: + previous_meetings[pair] = 0 + + # allocation_attempts : AllocationEnsemble = AllocationEnsemble() + allocation_attempts: list[AllocationEnsemble] = [ + AllocationEnsemble() + for _ in range(1) + ] + + + for round_no in range(n_rounds): + if progress_bar: progress_bar(round_no +1) + + + if not(isinstance(tables, int)): + no_tables = tables[round_no] + else: + no_tables = tables + + no_larger_tables = m_data % no_tables + + seats = math.ceil(m_data / no_tables) + + min_cluster_tables = math.ceil(no_cluster_agents /seats) + + n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) + no_smaller_tables = no_tables - no_larger_tables + + if no_larger_tables == 0: + template = [[None for s in range(seats)] for r in range(no_smaller_tables)] + else: + template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ + [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] + + meetings_previous_round = previous_meetings.copy() + + '''round_assign_pre, round_assign_swap, meetings_pre, ''' + allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, + order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, + pareto_prob, random, fields) + + allocation = Allocation( + ParticipantGroup(p_id for p_id in group) + for group in allocation + ) + for n, ensemble in enumerate(allocation_attempts): + ensemble.append(allocation) + + '''pre_occurences = {} + for value in meetings_pre.values(): + pre_occurences[value] = pre_occurences.get(value, 0) + 1 + pre_meeting_dist[round_no] = pre_occurences + occurences = {} + for value in previous_meetings.values(): + occurences[value] = occurences.get(value, 0) + 1 + post_meeting_dist[round_no] = occurences + + new_meetings = {} + for pair in previous_meetings: + if previous_meetings[pair]-meetings_previous_round[pair] == 1: + new_meetings[pair] = previous_meetings[pair] + round_meetings = {} + for value in new_meetings.values(): + round_meetings[value] = round_meetings.get(value, 0) + 1 + new_meetings_in_round[round_no] = round_meetings + + pre_demog_evaluations = {} + for index, table in enumerate(round_assign_pre): + pre_demog_evaluations[index] = {} + pre_demog_evaluations[index] = evaluate_demographics(round_assign_pre, index, peopledata_vals_used, order_diverse_dict, m_data)[0] + pre_balance[round_no] = averages_from_evals(pre_demog_evaluations) + post_demog_evaluations = {} + for index, table in enumerate(round_assign_swap): + post_demog_evaluations[index] = {} + post_demog_evaluations[index] = evaluate_demographics(round_assign_swap, index, peopledata_vals_used, order_diverse_dict, m_data)[0] + post_balance[round_no] = averages_from_evals(post_demog_evaluations) + + allocations_list[round_no] = round_assign_swap + + allocations_list, pre_meeting_dist, post_meeting_dist, new_meetings_in_round, pre_balance, post_balance, + ''' + + # print(allocation) + # print(previous_meetings) + + values = [0] * 5 + + #for key, value in previous_meetings.items(): + # i = 0 + # print("issue") + # for table in allocation: + # + # print(i) + # for pid in table: + # if pid in key: + # print(key) + # values[i] = values[i] + value + # i = i + 1 + #print(values, "values") + + + #for table in allocation: + # for pid in table: + # pid_meets = (pid in x for x in previous_meetings.keys()) + # print(pid_meets) + # break + #break + meet0[round_no] = sum(x == 0 for x in previous_meetings.values()) + meet1[round_no] = sum(x == 1 for x in previous_meetings.values()) + meet2[round_no] = sum(x == 2 for x in previous_meetings.values()) + meet3[round_no] = sum(x == 3 for x in previous_meetings.values()) + + + #meet4[round_no] = sum(x == 4 for x in previous_meetings.values()) + #meet5[round_no] = sum(x == 5 for x in previous_meetings.values()) + #meet6[round_no] = sum(x == 6 for x in previous_meetings.values()) + #meet7[round_no] = sum(x == 7 for x in previous_meetings.values()) + #meet8[round_no] = sum(x == 8 for x in previous_meetings.values()) + #meet9[round_no] = sum(x == 9 for x in previous_meetings.values()) + #meet10[round_no] = sum(x == 10 for x in previous_meetings.values()) + + #previous_meetings_max[round_no] = max(previous_meetings.values()) + #previous_meetings_min[round_no] = min(previous_meetings.values()) + #round_meetings_mean[round_no] = statistics.mean(previous_meetings.values()) + #print(round_meetings_mean[0:round_no+1]) + #print(statistics.mean(round_meetings_mean[0:round_no+1])) + #total_meetings_mean[round_no] = statistics.mean(round_meetings_mean[0:round_no+1]) + + #print(round_meetings_mean) + # if pareto_prob == 0.3: + # filename = '10alloc2.csv' + # elif pareto_prob == 0.5: + # filename = '10alloc.csv' + # else: + # filename = '10alloc3.csv' + + # with open(filename, 'w', newline='') as csvfile: + # writer = csv.writer(csvfile) + # writer.writerow(['round_no', 'meet0', 'meet1', 'meet2', 'meet3', 'meet4', 'meet5', 'meet6', 'meet7', 'meet8', 'meet9', 'meet10']) + # for i in range(n_rounds): + # writer.writerow([i, meet0[i], meet1[i], meet2[i], meet3[i], meet4[i], meet5[i], meet6[i], meet7[i], meet8[i], meet9[i], meet10[i]]) + return allocation_attempts, meet0, meet1, meet2, meet3 + + +def calculate_ideal_balance(cats_diverse, + m_data, + people): + ideal_balance = {} + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for row in people: + for i, category in enumerate(cats_diverse[demog]): + if row[demog] == category: + counts[i] += 1 + ideal_balance[demog] = [count / m_data for count in counts] + + + return ideal_balance + + +def averages_from_evals(evaluations: dict): + values_per_key = {} + + for nested_dict in evaluations.values(): + for key, value in nested_dict.items(): + if key not in values_per_key: + values_per_key[key] = [] + values_per_key[key].append(value) + averages = {} + for key in values_per_key: + lst = values_per_key[key] + averages[key] = sum(lst) / len(lst) + return averages + + +def run_round(template, + n_swap_loops, + seats, + m_data, + manual_pids, + n_cluster_tables, + cats_cluster, + cats_diverse, + people, + val_cluster, + no_tables, + previous_meetings, + pareto_prob, + random, + fields) -> Allocation: + allocations = copy.deepcopy(template) + + all_pids = list(range(m_data)) + + shuffled_pids = all_pids.copy() + + random.shuffle(shuffled_pids) + + shuffled_pids = [x for x in shuffled_pids if x not in manual_pids] + + cluster_table_index = list(range(n_cluster_tables)) + + if len(cats_cluster) == 1: + cluster_individuals = [] + for index, person in enumerate(people): + if person[next(iter(cats_cluster))] == val_cluster: + cluster_individuals.append(index) + cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] + + chosen_chair = 0 + + total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) + + if len(cluster_individuals) > total_clustering_spaces: + raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") + for agent in cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % len(cluster_table_index) + seat_no = math.floor( + chosen_chair / len(cluster_table_index) % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 + else: + cluster_individuals = [] + + non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] + chosen_chair = 0 + + for agent in non_cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % no_tables + seat_no = math.floor(chosen_chair / no_tables % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 + + if n_swap_loops == 1: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random, fields) + else: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random, fields) + for swap_round in range(1, n_swap_loops): + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, + pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, + m_data, pareto_prob, random, fields) + + raw_meetings = previous_meetings.copy() + + for sublist in pareto_allocations: + + for i in range(len(sublist)): + + for j in range(i + 1, len(sublist)): + pair = (min(sublist[i], sublist[j]), + max(sublist[i], sublist[j])) + + previous_meetings[pair] += 1 + + for sublist in allocations: + for i in range(len(sublist)): + for j in range(i + 1, len(sublist)): + pair = (min(sublist[i], sublist[j]), + max(sublist[i], sublist[j])) + # Increment count for the pair in the dictionary + raw_meetings[pair] += 1 + + this_alloc = Allocation( + ParticipantGroup(list) + for list in pareto_allocations + ) + + '''allocations, pareto_allocations, raw_meetings, ''' + + return this_alloc + + +def pareto_swaps(shuffled_pids, + cluster_individuals, + cluster_table_index, + temp_allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random, + fields): + temp_allocations_update = temp_allocations.copy() + + table_meeting_evaluations = {} + table_demog_evaluations = {} + for index, table in enumerate(temp_allocations_update): + table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) + table_demog_evaluations[index] = {} + + table_demog_evaluations[index] = evaluate_demographics( + temp_allocations_update, index, people, cats_diverse, m_data, fields) + + for pid in shuffled_pids: + + for index, table in enumerate(temp_allocations_update): + if pid in table: + table_no = index + + pid_info = {key: people[pid][key] + for key in people[pid] if key in cats_diverse} + + candidate_demogs = {} + + for demog in cats_diverse: + candidate_demogs[demog] = table_demog_evaluations[table_no][1][demog][pid_info[demog]] + + candidate_profiles = generate_combinations(candidate_demogs, pid_info) + candidate_swaps = {} + important_scores = {} + + print(candidate_profiles) + for profile in candidate_profiles: + if pid in cluster_individuals: + candidate_swap_tables = [x for x in table_demog_evaluations if ( + x != table_no) and (x in cluster_table_index)] + else: + candidate_swap_tables = [ + x for x in table_demog_evaluations if x != table_no] + for candidate_table in candidate_swap_tables: + pareto_score = 0 + pareto_profile = table_demog_evaluations[candidate_table][1] + + table_valid = True + for index, demog in enumerate(pareto_profile): + diverse_one = False + diverse_two = False + diverse_three = False + if pid_info[demog] in pareto_profile[demog][profile[index]]: + + #print(fields[list(cats_diverse.keys())[list(cats_diverse.values()).index()]]) + #diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cats_labels)]] + diversity_val = fields[demog] + if diversity_val == FieldMode.Diversify_3: + pareto_score += 0.25 + elif diversity_val == FieldMode.Diversify_2: + pareto_score += 0.5 + else: + pareto_score += 1 + elif pid_info[demog] != profile[index]: + table_valid = False + break + + if table_valid: + if pid in cluster_individuals: + for swap_pid in temp_allocations_update[candidate_table]: + if swap_pid not in manual_pids: + if tuple(people[swap_pid][key] for key in people[swap_pid] if + key in cats_diverse) == profile: + candidate_swaps[swap_pid] = pareto_score + \ + candidate_profiles[profile] + important_scores[swap_pid] = pareto_score + else: + for swap_pid in temp_allocations_update[candidate_table]: + if swap_pid not in cluster_individuals: + if swap_pid not in manual_pids: + if tuple(people[swap_pid][key] for key in people[swap_pid] if + key in cats_diverse) == profile: + + + + candidate_swaps[swap_pid] = pareto_score + \ + candidate_profiles[profile] + + important_scores[swap_pid] = pareto_score + + if len(candidate_swaps) == 0: + continue + + candidate_meetings = {} + for swap in candidate_swaps: + candidate_meetings[swap] = evaluate_swap(pid, swap, temp_allocations_update, table_meeting_evaluations, + previous_meetings) + + candidate_swaps = {key: value for key, value in candidate_swaps.items() if ( + candidate_swaps[key] > 0) or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0)} + + if len(candidate_swaps) == 0: + continue + + distinct_candidates = {} + for distinct_value in {value for value in candidate_swaps.values()}: + + distinct_keys = { + key for key, value in candidate_swaps.items() if value == distinct_value} + max_meetings = max( + value for key, value in candidate_meetings.items() if key in distinct_keys) + + distinct_candidates.update({key: value for key, value in candidate_swaps.items( + ) if (value == distinct_value) and (candidate_meetings[key] == max_meetings)}) + distinct_meetings = {key: value for key, value in candidate_meetings.items( + ) if key in distinct_candidates} + + reverse_mapping = {} + for key, value in distinct_candidates.items(): + if value not in reverse_mapping: + reverse_mapping[value] = [] + reverse_mapping[value].append(key) + + final_candidates = {} + for value, keys in reverse_mapping.items(): + final_candidates[random.choice(keys)] = value + final_meetings = { + key: value for key, value in distinct_meetings.items() if key in final_candidates} + + keys_to_remove = set() + + for key in final_meetings.keys(): + if any(final_meetings[other_key] >= final_meetings[key] and final_candidates[other_key] > final_candidates[ + key] for other_key in final_meetings.keys() if other_key != key): + keys_to_remove.add(key) + for key in keys_to_remove: + del final_meetings[key] + del final_candidates[key] + final_swap = select_key(final_candidates, final_meetings, pareto_prob, random, important_scores) + if final_swap == None: + continue + + for index, table in enumerate(temp_allocations_update): + if final_swap in table: + swap_table = index + + temp_allocations_update[table_no] = [ + final_swap if x == pid else x for x in temp_allocations_update[table_no]] + temp_allocations_update[swap_table] = [ + pid if x == final_swap else x for x in temp_allocations_update[swap_table]] + + for index in [table_no, swap_table]: + table_meeting_evaluations[index] = evaluate_meetings( + temp_allocations_update[index], previous_meetings) + table_demog_evaluations[index] = {} + + table_demog_evaluations[index] = evaluate_demographics( + temp_allocations_update, index, people, cats_diverse, m_data, fields) + + + return temp_allocations_update + + +def select_key(pareto, + meet, + pareto_prob, + random, + important_scores): + #areto_prob_copy = pareto_prob.copy() + # if important_scores[pareto] == 0.5: + # pareto_prob_copy = 0.275 + # elif important_scores[pareto] == 0.25: + # pareto_prob_copy = 0.2 + pareto_copy = pareto.copy() + meet_copy = meet.copy() + total_a = sum(pareto_copy.values()) + + if random.random() < pareto_prob: + if len(pareto_copy) == 1: + return next(iter(pareto_copy.keys())) + + cumulative_prob_a = {} + cumulative_sum = 0 + for key, value in pareto_copy.items(): + cumulative_sum += value / total_a + cumulative_prob_a[key] = cumulative_sum + + rand_num = random.random() + for key, prob in cumulative_prob_a.items(): + if rand_num <= prob: + return key + else: + meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} + if len(meet_copy) == 0: + return None + if len(meet_copy) == 1: + return next(iter(meet_copy.keys())) + + total_b = sum(meet_copy.values()) + cumulative_prob_b = {} + cumulative_sum = 0 + for key, value in meet_copy.items(): + cumulative_sum += value / total_b + cumulative_prob_b[key] = cumulative_sum + rand_num = random.random() + + for key, prob in cumulative_prob_b.items(): + if rand_num <= prob: + return key + + +def evaluate_swap(original_id, + swap_id, + allocations, + table_meeting_evaluations, + previous_meetings): + + for index, table in enumerate(allocations): + if swap_id in table: + swap_table = index + if original_id in table: + table_no = index + + original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( + x for x in table_meeting_evaluations[swap_table].values()) + original_table = allocations[table_no] + swap_table = allocations[swap_table] + original_table_2 = [swap_id if x == original_id else x for x in original_table] + swap_table_2 = [original_id if x == swap_id else x for x in swap_table] + meetings_1 = evaluate_meetings(original_table_2, previous_meetings) + meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) + + new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) + + return original_meetings - new_meetings + + +def generate_combinations(demogs, + info): + demographics = list(demogs.keys()) + + combinations_count = {} + + for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): + combination = tuple(values) + count = len(demogs) - \ + sum(1 for v in combination if v in info.values()) + combinations_count[combination] = count + return combinations_count + + +def evaluate_meetings(table, + previous_meetings): + + total_meetings = {} + for i in range(len(table)): + for j in range(i + 1, len(table)): + agent1, agent2 = min(table[i], table[j]), max( + table[i], table[j]) + # Sum the values from pairs_dict for the pair of agents + total_meetings[agent1] = total_meetings.get( + agent1, 0) + previous_meetings.get((agent1, agent2), 0) + total_meetings[agent2] = total_meetings.get( + agent2, 0) + previous_meetings.get((agent1, agent2), 0) + return (total_meetings) + + +def evaluate_demographics(temp_allocations, + table_no, + people, + cats_diverse, + m_data, + fields): + table = temp_allocations[table_no] + + + + table_data = {} + for index in table: + table_data[index] = people[index] + + + ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) + + table_balance = {} + table_actions = {} + table_distances = {} + table_length = len(table) + + i = 0 + + + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for person in table_data.values(): + for i, category in enumerate(cats_diverse[demog]): + if person.get(demog) == category: + counts[i] += 1 + table_balance[demog] = [count / table_length for count in counts] + + table_distances[demog] = sum([abs(x - y) for x, y in zip( + ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) + + table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], + len(table), fields, cats_diverse) + + + + return table_distances, table_actions + + +def evaluate_actions(ideal_dist, + table_dist, + cat_labels, + table_size, + fields, + cats_diverse): + table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] + + actions = {} + + #print(list(mydict.keys())[list(mydict.values()).index(16)]) + #mydict.keys()[mydict.values().index(16)] + diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)]] + #k = fields[cats_diverse[cat_labels]] + for index, label in enumerate(cat_labels): + actions_for_label = [] + if table_dist[index] > ideal_dist[index]: + for a, b in zip(table_discrepancies, cat_labels): + if diversity_val == FieldMode.Diversify_1 and a < 0: + actions_for_label.append(b) + elif diversity_val == FieldMode.Diversify_2 and a < -0.2: + actions_for_label.append(b) + elif diversity_val == FieldMode.Diversify_3 and a < -0.7: + actions_for_label.append(b) + actions[label] = actions_for_label + + + + # for i in range (0,len(actions)): + # if len(actions[i]) != 0: + # for j in range (0,len(actions[i])): + # actions[i][j] = math.ceil(actions[i][j] * 0.5) + + + return actions + + diff --git a/groupselect/algorithms/algorithm_legacy.py b/groupselect/algorithms/algorithm_legacy.py index 9a97ef4..10eb358 100644 --- a/groupselect/algorithms/algorithm_legacy.py +++ b/groupselect/algorithms/algorithm_legacy.py @@ -1,3 +1,5 @@ +import csv +import statistics from typing import Callable import numpy as np @@ -15,6 +17,30 @@ def algorithm_legacy(participants: np.ndarray[int], n_attempts: int = 100, seed: None | int = None) -> AllocatorResult: + print("hoho") + + meet1 = [0] * 10 + m_data = participants.shape[0] + + + + peopledata_vals_used = [{} for i in range(m_data)] + order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + + lister = [None] * len(order_diverse) + Y = participants[:, order_diverse] + for i in range(0, len(order_diverse)): + lister[i] = [int(item[i]) for item in Y] + + lister[i] = np.unique(lister[i]) + lister[i] = lister[i].tolist() + order_diverse_dict = dict(zip(order_diverse, lister)) + + for i in range(m_data): + for j in order_cluster + order_diverse: + peopledata_vals_used[i][j] = int(participants[i][j]) + # Check argument: seed if not (seed is None or isinstance(seed, int)): raise Exception('Random number seed must be integer.') @@ -32,7 +58,7 @@ def algorithm_legacy(participants: np.ndarray[int], raise Exception('Argument n_attempts must be positive integer.') # Check that there is at least one diversification field defined. - if not FieldMode.Diversify in fields.values(): + if not FieldMode.Diversify_1 in fields.values(): raise Exception('Error: One diversification field required!', 'You have to set at least one field that is ' 'used to diversify people across groups.') @@ -75,6 +101,44 @@ def algorithm_legacy(participants: np.ndarray[int], for group in allocation ) + #distance = {} + #index = 0 + #for table in allocation: + # print(index) + # distance[i] = evaluate_demographics( + # allocation, index, peopledata_vals_used, order_diverse_dict, m_data) + # index += 1 + + #print(fields) + #print(allocation) + #print(participants) + + #fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify_1] + + #groups_list = [ + # g_id + # for g_id, group in enumerate(allocation) + # if len(group) <= n_ppgr + #] + field_val = [0,1] + #fielders = [] + #for field_id in fields_diversify: + # for val in field_val: + # field_value_counts = { + # g_id: _count_categories(allocation[g_id], field_id, val, participants) + # for g_id in groups_list + # } + # field_val_counts_min = min(field_value_counts.items(), key=lambda x: x[1])[1] + #groups_list = [ + # g_id + # for g_id in groups_list + # if field_value_counts[g_id] == field_val_counts_min + #] + # fielders.append(field_value_counts) + + + #print("here ", distance) + # Append allocation to ensemble. ensemble.append(allocation) @@ -93,6 +157,64 @@ def algorithm_legacy(participants: np.ndarray[int], key=lambda ensemble: ensemble.calc_n_meetings_alo(), ) + previous_meetings = {} + + for i in range(m_data): + for j in range( i +1, m_data): + pair = (i ,j) + if pair not in previous_meetings: + previous_meetings[pair] = 0 + + + final = [0] * 10 + div_mean = [0] * 10 + distance = {} + print(allocation_sample_max[0]) + for j in range(0, 10): + print("j",j) + for i in range(0, 11): + print("i",i) + distance[i] = evaluate_demographics( + allocation_sample_max[j], i, peopledata_vals_used, order_diverse_dict, m_data) + + + this = [y for x, y in distance.items()] + this3 = [x[8] for x in this] + # for i in range (0, len(distance)): + # if (j == 983): + # print(this3, "this3") + final[j] = statistics.mean(this3) + # print(final[0:j+1]) + div_mean[j] = statistics.mean(final[0:j + 1]) + + for n in range(0, 10): + print("n",n) + for m in range (0, len(allocation_sample_max[n])): + print("m",m) + for i in range (0, len(allocation_sample_max[n][m])): + + print(allocation_sample_max[n][m]) + pid_1 = allocation_sample_max[n][m][i] + for j in range (i+1, len(allocation_sample_max[n][m])): + print(allocation_sample_max[n][m][j]) + pid_2 = allocation_sample_max[n][m][j] + pair = (min(pid_1,pid_2), max(pid_1,pid_2)) + previous_meetings[pair] += 1 + meet1[n] = sum(x == 1 for x in previous_meetings.values()) + + print(previous_meetings) + print(div_mean) + print(allocation_sample_max) + print(meet1) + + filename = ('meetsvsdiv(alloc = photo.csv') + with open(filename, 'w', newline='') as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["round div_mean", "total_div_mean", "meet1"]) + for j in range(10): + # writer.writerow([1,2,3,4]) + writer.writerow([final[j], div_mean[j], meet1[j]]) + # Create AllocatorResult from sample max and return return AllocatorResult(ensemble=allocation_sample_max) @@ -118,7 +240,7 @@ def _allocate_legacy_once(participants: np.ndarray[int], # Split fields into clustering and diversification fields. Order participant IDs # by clustering and diversification field values. fields_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify] + fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify_1] p_ids_ordered = np.lexsort(participants[:, (fields_cluster + fields_diversify)[::-1]].T) # Loop over participants and allocate to a group. @@ -151,7 +273,6 @@ def _allocate_person(p_id: int, for g_id, group in enumerate(allocation) if len(group) < n_ppgr ] - # Loop over clustering fields. superior_field_filters = {} for field_id in fields_cluster: @@ -193,6 +314,7 @@ def _allocate_person(p_id: int, groups_list = groups_list_tmp superior_field_filters[field_id] = field_val + # Loop over diversification fields. for field_id in fields_diversify: field_val = p_details[field_id] @@ -227,3 +349,60 @@ def _count_categories(group: ParticipantGroup, field_id: int, field_val: int, pa def _number_of_people_filtered(participants: np.ndarray[int], fields: dict[int, int]): return (participants.T[list(fields.keys())] == list(fields.values())).all(axis=0).sum() + +def evaluate_demographics(temp_allocations, + table_no, + people, + cats_diverse, + m_data): + table = temp_allocations[table_no] + + print("far") + + print(table) + + table_data = {} + for index in table: + table_data[index] = people[index] + + + ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) + + table_balance = {} + table_actions = {} + table_distances = {} + table_length = len(table) + + i = 0 + + + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for person in table_data.values(): + for i, category in enumerate(cats_diverse[demog]): + if person.get(demog) == category: + counts[i] += 1 + table_balance[demog] = [count / table_length for count in counts] + + table_distances[demog] = sum([abs(x - y) for x, y in zip( + ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) + + + print("epis") + return table_distances + +def calculate_ideal_balance(cats_diverse, + m_data, + people): + print("prob") + ideal_balance = {} + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for row in people: + for i, category in enumerate(cats_diverse[demog]): + if row[demog] == category: + counts[i] += 1 + ideal_balance[demog] = [count / m_data for count in counts] + + print("ideal_balance") + return ideal_balance \ No newline at end of file From 444c0544b3a25dbc677457d331099bd930d8dd55 Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Mon, 16 Mar 2026 12:28:07 +0000 Subject: [PATCH 04/26] added sliding weighting values --- groupselect/algorithms/algorithm_heuristic.py | 230 +++++++++++------- 1 file changed, 136 insertions(+), 94 deletions(-) diff --git a/groupselect/algorithms/algorithm_heuristic.py b/groupselect/algorithms/algorithm_heuristic.py index 860194f..d749a3e 100644 --- a/groupselect/algorithms/algorithm_heuristic.py +++ b/groupselect/algorithms/algorithm_heuristic.py @@ -19,9 +19,35 @@ def algorithm_heuristic(participants: np.ndarray[int], manuals: dict[int, int], progress_func: None | Callable = None, n_attempts: int = 3, - seed: None | int = None): + seed: None | int = None, + prob1: float = -1, + prob2: float = -1, + prob3: float = -1, + prob4: float = -1, + prob5: float = -1,): + + + + pareto_prob = [0] * 5 + pareto_prob[0] = prob1/20 + + pareto_prob[1] = prob2/20 + + pareto_prob[2] = prob3/20 + + pareto_prob[3] = prob4/20 + + pareto_prob[4] = prob5/20 + + pareto_probs = {} + i = 0 + for k, v in fields.items(): + if v == FieldMode.Diversify_1: + pareto_probs[k] = pareto_prob[i] + i = i + 1 + nallocations = len(groups) progress_bar = progress_func tables = groups[0][0] @@ -30,26 +56,11 @@ def algorithm_heuristic(participants: np.ndarray[int], seats = value - rank1 = False - rank2 = False - rank3 = False - order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] - for k in order_diverse: - if fields[k] == FieldMode.Diversify_1: - rank1 = True - elif fields[k] == FieldMode.Diversify_2: - rank2 = True - else: - rank3 = True - if rank1 == True: - pareto_prob = 0.5 - elif rank2 == True: - pareto_prob = 0.275 - elif rank3 == True: - pareto_prob = 0.2 + + order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 ]#or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] @@ -93,8 +104,6 @@ def algorithm_heuristic(participants: np.ndarray[int], m_data = participants.shape[0] - - ''' cluster_tables: int, m_data: int, @@ -144,9 +153,12 @@ def algorithm_heuristic(participants: np.ndarray[int], if n_swap_loops < 1: raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") - n_results, meet0, meet1, meet2, meet3= allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_prob, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) + + n_results, meet0, meet1, meet2, meet3, meet4, meet5 = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) allocation_results = n_results + + meetval = (meet1[9]+(meet2[9]/2)+meet3[9]/3 +meet4[9]/4+meet5[9]/5)/ (meet0[9]+meet1[9]+meet2[9]+meet3[9]+meet4[9]+meet5[9]) ''' allocations = [] for result in n_results[0]: allocations.append(n_results[0][result]) @@ -194,49 +206,61 @@ def algorithm_heuristic(participants: np.ndarray[int], # distance[i] = evaluate_demographics(i, peopledata_vals_used, order_diverse_dict, m_data, fields) #distances = evaluate_demographics() - #print(distances, "distances") - + #print(distances, "di - print(allocation_results[0][0]) final = [0] * nallocations div_mean = [0] * nallocations minimum = [0] * nallocations maximum = [0] * nallocations final_results2 = max(allocation_results) + ''' for j in range(nallocations): + for i in range (0, tables): - distance[i] = evaluate_demographics(allocation_results[0][j], i, peopledata_vals_used, order_diverse_dict, m_data, fields) + distance[i] = evaluate_demographics(allocation_results[0][j], i, peopledata_vals_used, order_diverse_dict, m_data, fields, pareto_probs) + this = [y for x,y in distance.items()] this2 = [x for x,y in this] this3 = [x[8] for x in this2] + #this4 = [x[7] for x in this2] + #this5 = [x[6] for x in this2] + # print("mu") #for i in range (0, len(distance)): #if (j == 983): #print(this3, "this3") + # print("phi") + #final3 = statistics.mean(this3) + #final4 = statistics.mean(this4) + #final5 = statistics.mean(this5) + #final[j] = statistics.mean([final3, final4, final5]) final[j] = statistics.mean(this3) - maximum[j] = max(this3) - minimum[j] = min(this3) + #maximum[j] = max(this3) + #minimum[j] = min(this3) #print(final[0:j+1]) + # print("omega") div_mean[j] = statistics.mean(final[0:j+1]) #print(final[j]) #print(maximum[j]) #print(minimum[j]) #meet_mean = statistics.mean(prev_meet_mean) - if pareto_prob == 0.275: - rank = 2 - elif pareto_prob == 0.5: - rank = 1 - else: - rank = 3 - - filename = ('meetsvsdiv(alloc =' + nallocations.__str__() + ', rank =' + rank.__str__() + ') urb.csv') + #print("delta") + #if pareto_prob == 0.275: + # rank = 2 + #elif pareto_prob == 0.5: + # rank = 1 + #else: + # rank = 3 + + #filename = ('mutli cats, rank =' + rank.__str__() + ')' + name + '.csv') + filename = (name + ".csv") with open(filename, 'w', newline='') as csvfile: writer = csv.writer(csvfile) writer.writerow(["round div_mean","total_div_mean", "meet0", "meet1", "meet2", "meet3"]) - for j in range(nallocations): - #writer.writerow([1,2,3,4]) - writer.writerow([final[j], div_mean[j], meet0[j], meet1[j], meet2[j], meet3[j]]) - + for j in range(0, 10): + # writer.writerow([1,2,3,4]) + writer.writerow([final[j], div_mean[j], meet0[j], meet1[j], meet2[j], meet3[j], meetval]) +''' return AllocatorResult(final_results2) def allocate(tables, @@ -246,7 +270,7 @@ def allocate(tables, m_data, nallocations, cluster_tables, - pareto_prob, + pareto_probs, n_swap_loops, progress_bar, previous_meetings, @@ -323,7 +347,7 @@ def allocate(tables, '''round_assign_pre, round_assign_swap, meetings_pre, ''' allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, - pareto_prob, random, fields) + pareto_probs, random, fields) allocation = Allocation( ParticipantGroup(p_id for p_id in group) @@ -395,10 +419,8 @@ def allocate(tables, meet1[round_no] = sum(x == 1 for x in previous_meetings.values()) meet2[round_no] = sum(x == 2 for x in previous_meetings.values()) meet3[round_no] = sum(x == 3 for x in previous_meetings.values()) - - - #meet4[round_no] = sum(x == 4 for x in previous_meetings.values()) - #meet5[round_no] = sum(x == 5 for x in previous_meetings.values()) + meet4[round_no] = sum(x == 4 for x in previous_meetings.values()) + meet5[round_no] = sum(x == 5 for x in previous_meetings.values()) #meet6[round_no] = sum(x == 6 for x in previous_meetings.values()) #meet7[round_no] = sum(x == 7 for x in previous_meetings.values()) #meet8[round_no] = sum(x == 8 for x in previous_meetings.values()) @@ -425,7 +447,7 @@ def allocate(tables, # writer.writerow(['round_no', 'meet0', 'meet1', 'meet2', 'meet3', 'meet4', 'meet5', 'meet6', 'meet7', 'meet8', 'meet9', 'meet10']) # for i in range(n_rounds): # writer.writerow([i, meet0[i], meet1[i], meet2[i], meet3[i], meet4[i], meet5[i], meet6[i], meet7[i], meet8[i], meet9[i], meet10[i]]) - return allocation_attempts, meet0, meet1, meet2, meet3 + return allocation_attempts, meet0, meet1, meet2, meet3, meet4, meet5 def calculate_ideal_balance(cats_diverse, @@ -471,7 +493,7 @@ def run_round(template, val_cluster, no_tables, previous_meetings, - pareto_prob, + pareto_probs, random, fields) -> Allocation: allocations = copy.deepcopy(template) @@ -527,14 +549,14 @@ def run_round(template, if n_swap_loops == 1: pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random, fields) + cats_diverse, manual_pids, previous_meetings, m_data, pareto_probs, random, fields) else: pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random, fields) + cats_diverse, manual_pids, previous_meetings, m_data, pareto_probs, random, fields) for swap_round in range(1, n_swap_loops): pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, - m_data, pareto_prob, random, fields) + m_data, pareto_probs, random, fields) raw_meetings = previous_meetings.copy() @@ -575,7 +597,7 @@ def pareto_swaps(shuffled_pids, manual_pids, previous_meetings, m_data, - pareto_prob, + pareto_probs, random, fields): temp_allocations_update = temp_allocations.copy() @@ -587,7 +609,8 @@ def pareto_swaps(shuffled_pids, table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, fields) + temp_allocations_update, index, people, cats_diverse, m_data, fields, pareto_probs) + for pid in shuffled_pids: @@ -605,9 +628,13 @@ def pareto_swaps(shuffled_pids, candidate_profiles = generate_combinations(candidate_demogs, pid_info) candidate_swaps = {} - important_scores = {} - print(candidate_profiles) + for k, v in cats_diverse.items(): + demog_total = k + + demog_scores = [[False] * (demog_total)] * m_data + + for profile in candidate_profiles: if pid in cluster_individuals: candidate_swap_tables = [x for x in table_demog_evaluations if ( @@ -616,29 +643,30 @@ def pareto_swaps(shuffled_pids, candidate_swap_tables = [ x for x in table_demog_evaluations if x != table_no] for candidate_table in candidate_swap_tables: + demog_pareto = [False] * (demog_total) pareto_score = 0 pareto_profile = table_demog_evaluations[candidate_table][1] - table_valid = True for index, demog in enumerate(pareto_profile): - diverse_one = False - diverse_two = False - diverse_three = False + if pid_info[demog] in pareto_profile[demog][profile[index]]: #print(fields[list(cats_diverse.keys())[list(cats_diverse.values()).index()]]) #diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cats_labels)]] diversity_val = fields[demog] - if diversity_val == FieldMode.Diversify_3: - pareto_score += 0.25 - elif diversity_val == FieldMode.Diversify_2: - pareto_score += 0.5 - else: - pareto_score += 1 + #if diversity_val == FieldMode.Diversify_3: + # pareto_score += 1 + # rank3_score += 1 + # elif diversity_val == FieldMode.Diversify_2: + # pareto_score += 1 + # rank2_score += 1 + #else: + pareto_score += 1 + demog_pareto[demog-1] = True + elif pid_info[demog] != profile[index]: table_valid = False break - if table_valid: if pid in cluster_individuals: for swap_pid in temp_allocations_update[candidate_table]: @@ -647,7 +675,11 @@ def pareto_swaps(shuffled_pids, key in cats_diverse) == profile: candidate_swaps[swap_pid] = pareto_score + \ candidate_profiles[profile] - important_scores[swap_pid] = pareto_score + + for i in range(0, demog_total): + if demog_pareto[i - 1] == True: + demog_scores[swap_pid][i - 1] = True + else: for swap_pid in temp_allocations_update[candidate_table]: if swap_pid not in cluster_individuals: @@ -660,15 +692,17 @@ def pareto_swaps(shuffled_pids, candidate_swaps[swap_pid] = pareto_score + \ candidate_profiles[profile] - important_scores[swap_pid] = pareto_score + for i in range(0, demog_total): + if demog_pareto[i-1] == True: + demog_scores[swap_pid][i-1] = True + if len(candidate_swaps) == 0: continue - candidate_meetings = {} for swap in candidate_swaps: candidate_meetings[swap] = evaluate_swap(pid, swap, temp_allocations_update, table_meeting_evaluations, - previous_meetings) + previous_meetings) candidate_swaps = {key: value for key, value in candidate_swaps.items() if ( candidate_swaps[key] > 0) or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0)} @@ -688,13 +722,11 @@ def pareto_swaps(shuffled_pids, ) if (value == distinct_value) and (candidate_meetings[key] == max_meetings)}) distinct_meetings = {key: value for key, value in candidate_meetings.items( ) if key in distinct_candidates} - reverse_mapping = {} for key, value in distinct_candidates.items(): if value not in reverse_mapping: reverse_mapping[value] = [] reverse_mapping[value].append(key) - final_candidates = {} for value, keys in reverse_mapping.items(): final_candidates[random.choice(keys)] = value @@ -702,7 +734,6 @@ def pareto_swaps(shuffled_pids, key: value for key, value in distinct_meetings.items() if key in final_candidates} keys_to_remove = set() - for key in final_meetings.keys(): if any(final_meetings[other_key] >= final_meetings[key] and final_candidates[other_key] > final_candidates[ key] for other_key in final_meetings.keys() if other_key != key): @@ -710,7 +741,7 @@ def pareto_swaps(shuffled_pids, for key in keys_to_remove: del final_meetings[key] del final_candidates[key] - final_swap = select_key(final_candidates, final_meetings, pareto_prob, random, important_scores) + final_swap = select_key(final_candidates, final_meetings, pareto_probs, random, demog_scores, demog_total) if final_swap == None: continue @@ -727,9 +758,8 @@ def pareto_swaps(shuffled_pids, table_meeting_evaluations[index] = evaluate_meetings( temp_allocations_update[index], previous_meetings) table_demog_evaluations[index] = {} - table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, fields) + temp_allocations_update, index, people, cats_diverse, m_data, fields, pareto_probs) return temp_allocations_update @@ -737,19 +767,25 @@ def pareto_swaps(shuffled_pids, def select_key(pareto, meet, - pareto_prob, + pareto_probs, random, - important_scores): - #areto_prob_copy = pareto_prob.copy() - # if important_scores[pareto] == 0.5: - # pareto_prob_copy = 0.275 - # elif important_scores[pareto] == 0.25: - # pareto_prob_copy = 0.2 + demog_scores, + demog_total): + relevant_demogs = [] + k = list(pareto.keys())[0] + for i in range (0, demog_total): + if demog_scores[k][i] == True: + + relevant_demogs.append(pareto_probs[i+1]) + if relevant_demogs != []: + pareto_prob_copy = max(relevant_demogs) + else: + pareto_prob_copy = max(pareto_probs.values()) + pareto_copy = pareto.copy() meet_copy = meet.copy() total_a = sum(pareto_copy.values()) - - if random.random() < pareto_prob: + if random.random() < pareto_prob_copy: if len(pareto_copy) == 1: return next(iter(pareto_copy.keys())) @@ -844,7 +880,8 @@ def evaluate_demographics(temp_allocations, people, cats_diverse, m_data, - fields): + fields, + pareto_probs): table = temp_allocations[table_no] @@ -876,8 +913,7 @@ def evaluate_demographics(temp_allocations, ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], - len(table), fields, cats_diverse) - + len(table), fields, cats_diverse, pareto_probs) return table_distances, table_actions @@ -888,7 +924,8 @@ def evaluate_actions(ideal_dist, cat_labels, table_size, fields, - cats_diverse): + cats_diverse, + pareto_probs): table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] actions = {} @@ -896,17 +933,22 @@ def evaluate_actions(ideal_dist, #print(list(mydict.keys())[list(mydict.values()).index(16)]) #mydict.keys()[mydict.values().index(16)] diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)]] + + lister = list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)] + + #top_prob = max(pareto_probs) + threshold = -0.5 + pareto_probs[lister] #k = fields[cats_diverse[cat_labels]] for index, label in enumerate(cat_labels): actions_for_label = [] if table_dist[index] > ideal_dist[index]: for a, b in zip(table_discrepancies, cat_labels): - if diversity_val == FieldMode.Diversify_1 and a < 0: - actions_for_label.append(b) - elif diversity_val == FieldMode.Diversify_2 and a < -0.2: - actions_for_label.append(b) - elif diversity_val == FieldMode.Diversify_3 and a < -0.7: + if diversity_val == FieldMode.Diversify_1 and a < threshold: actions_for_label.append(b) + #elif diversity_val == FieldMode.Diversify_2 and a < -0.2: + # actions_for_label.append(b) + #elif diversity_val == FieldMode.Diversify_3 and a < -0.7: + # actions_for_label.append(b) actions[label] = actions_for_label From 760129715208ba969a1d8a59c42ba57ddb3c248f Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Mon, 16 Mar 2026 12:59:23 +0000 Subject: [PATCH 05/26] fixed testing error --- groupselect/algorithms/algorithm_heuristic.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/groupselect/algorithms/algorithm_heuristic.py b/groupselect/algorithms/algorithm_heuristic.py index d749a3e..47fa461 100644 --- a/groupselect/algorithms/algorithm_heuristic.py +++ b/groupselect/algorithms/algorithm_heuristic.py @@ -28,7 +28,7 @@ def algorithm_heuristic(participants: np.ndarray[int], - + print("he") pareto_prob = [0] * 5 pareto_prob[0] = prob1/20 @@ -40,7 +40,7 @@ def algorithm_heuristic(participants: np.ndarray[int], pareto_prob[3] = prob4/20 pareto_prob[4] = prob5/20 - + print("pareto_prob") pareto_probs = {} i = 0 for k, v in fields.items(): @@ -62,7 +62,7 @@ def algorithm_heuristic(participants: np.ndarray[int], order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 ]#or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] - + print("2") if len(order_cluster ) >=1: val_cluster = 'cluster' @@ -114,7 +114,7 @@ def algorithm_heuristic(participants: np.ndarray[int], seats = math.ceil(m_data /tables) previous_meetings = {} - + print("3") try: random = np.random.default_rng(seed) except: @@ -157,8 +157,9 @@ def algorithm_heuristic(participants: np.ndarray[int], n_results, meet0, meet1, meet2, meet3, meet4, meet5 = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) allocation_results = n_results - - meetval = (meet1[9]+(meet2[9]/2)+meet3[9]/3 +meet4[9]/4+meet5[9]/5)/ (meet0[9]+meet1[9]+meet2[9]+meet3[9]+meet4[9]+meet5[9]) + #print(meet1) + #meetval = (meet1[9]+(meet2[9]/2)+meet3[9]/3 +meet4[9]/4+meet5[9]/5)/ (meet0[9]+meet1[9]+meet2[9]+meet3[9]+meet4[9]+meet5[9]) + #print(meetval) ''' allocations = [] for result in n_results[0]: allocations.append(n_results[0][result]) From 4c8a8ffa322369dda9d8718d4a540e87dd84a218 Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Mon, 11 May 2026 11:05:18 +0100 Subject: [PATCH 06/26] newest version --- groupselect/algorithms/algorithm_dream.py | 1 - groupselect/algorithms/algorithm_heuristic.py | 146 ++++++++++++++---- groupselect/algorithms/algorithm_legacy.py | 89 +++++++---- 3 files changed, 176 insertions(+), 60 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 5f1307e..51d39fd 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -19,7 +19,6 @@ def algorithm_dream(participants: np.ndarray[int], n_attempts: int = 3, seed: None | int = None): - print("dream") nallocations = len(groups) progress_bar = progress_func tables = groups[0][0] diff --git a/groupselect/algorithms/algorithm_heuristic.py b/groupselect/algorithms/algorithm_heuristic.py index 47fa461..23b77a3 100644 --- a/groupselect/algorithms/algorithm_heuristic.py +++ b/groupselect/algorithms/algorithm_heuristic.py @@ -24,13 +24,27 @@ def algorithm_heuristic(participants: np.ndarray[int], prob2: float = -1, prob3: float = -1, prob4: float = -1, - prob5: float = -1,): - - - - print("he") - - pareto_prob = [0] * 5 + prob5: float = -1, + prob6: float = -1, + prob7: float = -1, + prob8: float = -1, + prob9: float = -1, + prob10: float = -1, + prob11: float = -1, + prob12: float = -1, + prob13: float = -1, + prob14: float = -1, + prob15: float = -1, + prob16: float = -1, + prob17: float = -1, + prob18: float = -1, + prob19: float = -1, + prob20: float = -1, + ): + + + + pareto_prob = [0] * 20 pareto_prob[0] = prob1/20 pareto_prob[1] = prob2/20 @@ -40,7 +54,22 @@ def algorithm_heuristic(participants: np.ndarray[int], pareto_prob[3] = prob4/20 pareto_prob[4] = prob5/20 - print("pareto_prob") + pareto_prob[5] = prob6/20 + pareto_prob[6] = prob7/20 + pareto_prob[7] = prob8/20 + pareto_prob[8] = prob9/20 + pareto_prob[9] = prob10/20 + pareto_prob[10] = prob11/20 + pareto_prob[11] = prob12/20 + pareto_prob[12] = prob13/20 + pareto_prob[13] = prob14/20 + pareto_prob[14] = prob15/20 + pareto_prob[15] = prob16/20 + pareto_prob[16] = prob17/20 + pareto_prob[17] = prob18/20 + pareto_prob[18] = prob19/20 + pareto_prob[19] = prob20/20 + pareto_probs = {} i = 0 for k, v in fields.items(): @@ -60,14 +89,9 @@ def algorithm_heuristic(participants: np.ndarray[int], order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 ]#or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1]#or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] - print("2") - if len(order_cluster ) >=1: - val_cluster = 'cluster' - else: - val_cluster = '' X = participants[:, order_cluster] @@ -91,7 +115,10 @@ def algorithm_heuristic(participants: np.ndarray[int], Y = np.unique(Y) - + if len(order_cluster ) >=1: + val_cluster = X[len(X)-1] + else: + val_cluster = '' order_cluster_dict = dict(zip(order_cluster, [list(X)])) order_diverse_dict = dict(zip(order_diverse, lister)) @@ -114,7 +141,6 @@ def algorithm_heuristic(participants: np.ndarray[int], seats = math.ceil(m_data /tables) previous_meetings = {} - print("3") try: random = np.random.default_rng(seed) except: @@ -143,12 +169,13 @@ def algorithm_heuristic(participants: np.ndarray[int], no_cluster_agents = 0 - if len(order_cluster_dict ) ==1: + cluster_key = next(iter(order_cluster_dict)) no_cluster_agents = sum(1 for person in peopledata_vals_used if person[cluster_key] == val_cluster) + n_swap_loops = int(swap_rounds) if n_swap_loops < 1: raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") @@ -156,16 +183,17 @@ def algorithm_heuristic(participants: np.ndarray[int], n_results, meet0, meet1, meet2, meet3, meet4, meet5 = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) + allocation_results = n_results #print(meet1) #meetval = (meet1[9]+(meet2[9]/2)+meet3[9]/3 +meet4[9]/4+meet5[9]/5)/ (meet0[9]+meet1[9]+meet2[9]+meet3[9]+meet4[9]+meet5[9]) #print(meetval) - ''' allocations = [] - for result in n_results[0]: - allocations.append(n_results[0][result]) - - allocation_group_outcome = allocations - + allocations = [] + #for result in n_results[0]: + # allocations.append(n_results[0][result]) + print("6") + #allocation_group_outcome = allocations + ''' d_mult = m_data// (tables**2) L_R = ((tables**2) * 8.5 * d_mult * (d_mult-1)) + d_mult * (m_data % (tables**2)) min_duplicates = max(0, L_R) @@ -190,11 +218,11 @@ def algorithm_heuristic(participants: np.ndarray[int], else: # all pairs have met allocation_group_links_pp = total_pairs - + ''' # maximum links from round 0 to 1 are a function of table size and number of tables - allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data + #allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data + - ''' # Select the sample with maximum number of meetings. # final_results1: list[AllocationEnsemble] = [ @@ -214,7 +242,11 @@ def algorithm_heuristic(participants: np.ndarray[int], minimum = [0] * nallocations maximum = [0] * nallocations final_results2 = max(allocation_results) - ''' + """ + if val_cluster == '': + it = 8 + else : + it = 7 for j in range(nallocations): for i in range (0, tables): @@ -222,7 +254,7 @@ def algorithm_heuristic(participants: np.ndarray[int], this = [y for x,y in distance.items()] this2 = [x for x,y in this] - this3 = [x[8] for x in this2] + this3 = [x[it] for x in this2] #this4 = [x[7] for x in this2] #this5 = [x[6] for x in this2] # print("mu") @@ -252,6 +284,58 @@ def algorithm_heuristic(participants: np.ndarray[int], # rank = 1 #else: # rank = 3 + print(prob1) + if pareto_prob[0] == 0.5: + if nallocations == 10: + name = "dreamgenraceage" + if nallocations == 11: + name = "dreamurbdietedu" + if nallocations == 12: + name = "dreamphotoaudioage" + if nallocations == 13: + name = "dreamracedietphoto" + if nallocations == 14: + name = "dreamageeduaudio" + if nallocations == 15: + name = "dreamgendietage" + if nallocations == 16: + name = "dreamraceeduaudio" + if nallocations == 17: + name = "dreamageurbphoto" + if pareto_prob[0] == 0.25: + if nallocations == 10: + name = "heurgenraceage" + if nallocations == 11: + name = "heururbdietedu" + if nallocations == 12: + name = "heurphotoaudioage" + if nallocations == 13: + name = "heurracedietphoto" + if nallocations == 14: + name = "heurageeduaudio" + if nallocations == 15: + name = "heurgendietage" + if nallocations == 16: + name = "heurraceeduaudio" + if nallocations == 17: + name = "heurageurbphoto" + if pareto_prob[0] != pareto_prob[1]: + if nallocations == 10: + name = "spreadgenraceage" + if nallocations == 11: + name = "spreadurbdietedu" + if nallocations == 12: + name = "spreadphotoaudioage" + if nallocations == 13: + name = "spreadracedietphoto" + if nallocations == 14: + name = "spreadageeduaudio" + if nallocations == 15: + name = "spreadgendietage" + if nallocations == 16: + name = "spreadraceeduaudio" + if nallocations == 17: + name = "spreadageurbphoto" #filename = ('mutli cats, rank =' + rank.__str__() + ')' + name + '.csv') filename = (name + ".csv") @@ -261,7 +345,8 @@ def algorithm_heuristic(participants: np.ndarray[int], for j in range(0, 10): # writer.writerow([1,2,3,4]) writer.writerow([final[j], div_mean[j], meet0[j], meet1[j], meet2[j], meet3[j], meetval]) -''' + """ + print(meet0, meet1, meet2, meet3, meet4, meet5, "meetsdud") return AllocatorResult(final_results2) def allocate(tables, @@ -416,6 +501,8 @@ def allocate(tables, # print(pid_meets) # break #break + + print(previous_meetings, "meeter1") meet0[round_no] = sum(x == 0 for x in previous_meetings.values()) meet1[round_no] = sum(x == 1 for x in previous_meetings.values()) meet2[round_no] = sum(x == 2 for x in previous_meetings.values()) @@ -773,6 +860,7 @@ def select_key(pareto, demog_scores, demog_total): relevant_demogs = [] + k = list(pareto.keys())[0] for i in range (0, demog_total): if demog_scores[k][i] == True: diff --git a/groupselect/algorithms/algorithm_legacy.py b/groupselect/algorithms/algorithm_legacy.py index 10eb358..cc24f00 100644 --- a/groupselect/algorithms/algorithm_legacy.py +++ b/groupselect/algorithms/algorithm_legacy.py @@ -17,12 +17,18 @@ def algorithm_legacy(participants: np.ndarray[int], n_attempts: int = 100, seed: None | int = None) -> AllocatorResult: - print("hoho") - + meet0 = [0] * 10 meet1 = [0] * 10 - m_data = participants.shape[0] + meet2 = [0] * 10 + meet3 = [0] * 10 + meet4 = [0] * 10 + meet5 = [0] * 10 + meet6 = [0] * 10 + meet7 = [0] * 10 + m_data = participants.shape[0] + peopledata_vals_used = [{} for i in range(m_data)] order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] @@ -81,7 +87,9 @@ def algorithm_legacy(participants: np.ndarray[int], for n, ensemble in enumerate(allocation_attempts): if progress_func is not None: progress_func(n) + i = 0 for n_gr, n_ppgr in groups: + i = i + 1 # Shuffle participant IDs. shuffle = list(range(len(participants))) random.shuffle(shuffle) @@ -143,6 +151,7 @@ def algorithm_legacy(participants: np.ndarray[int], ensemble.append(allocation) # Sample `n_allocation` allocations and repeat that `n_attempts` times. + allocation_samples: list[AllocationEnsemble] = [ AllocationEnsemble() for _ in range(n_attempts) @@ -151,6 +160,7 @@ def algorithm_legacy(participants: np.ndarray[int], for sample, choice in zip(allocation_samples, random.choice(n_attempts, n_attempts)): sample.append(allocation_attempts[choice][a_id]) + # Select the sample with maximum number of meetings. allocation_sample_max = max( allocation_samples, @@ -159,62 +169,85 @@ def algorithm_legacy(participants: np.ndarray[int], previous_meetings = {} + for i in range(m_data): for j in range( i +1, m_data): pair = (i ,j) if pair not in previous_meetings: previous_meetings[pair] = 0 - + """ final = [0] * 10 div_mean = [0] * 10 distance = {} - print(allocation_sample_max[0]) + print("6") + if order_cluster == []: + it = 8 + else: + it = 7 for j in range(0, 10): - print("j",j) - for i in range(0, 11): - print("i",i) + for i in range(0, 10): distance[i] = evaluate_demographics( allocation_sample_max[j], i, peopledata_vals_used, order_diverse_dict, m_data) this = [y for x, y in distance.items()] - this3 = [x[8] for x in this] + this3 = [x[it] for x in this] # for i in range (0, len(distance)): # if (j == 983): # print(this3, "this3") final[j] = statistics.mean(this3) # print(final[0:j+1]) div_mean[j] = statistics.mean(final[0:j + 1]) - + print("7") for n in range(0, 10): - print("n",n) for m in range (0, len(allocation_sample_max[n])): - print("m",m) + for i in range (0, len(allocation_sample_max[n][m])): - print(allocation_sample_max[n][m]) pid_1 = allocation_sample_max[n][m][i] for j in range (i+1, len(allocation_sample_max[n][m])): - print(allocation_sample_max[n][m][j]) pid_2 = allocation_sample_max[n][m][j] pair = (min(pid_1,pid_2), max(pid_1,pid_2)) previous_meetings[pair] += 1 + meet0[n] = sum(x == 0 for x in previous_meetings.values()) meet1[n] = sum(x == 1 for x in previous_meetings.values()) - - print(previous_meetings) - print(div_mean) - print(allocation_sample_max) - print(meet1) - - filename = ('meetsvsdiv(alloc = photo.csv') + meet2[n] = sum(x == 2 for x in previous_meetings.values()) + meet3[n] = sum(x == 3 for x in previous_meetings.values()) + meet4[n] = sum(x == 4 for x in previous_meetings.values()) + meet5[n] = sum(x == 5 for x in previous_meetings.values()) + meet6[n] = sum(x == 6 for x in previous_meetings.values()) + meet7[n] = sum(x == 7 for x in previous_meetings.values()) + print("8") + meetval = (meet1[9] + (meet2[9] / 2) + meet3[9] / 3 + meet4[9] / 4 + meet5[9] / 5 + meet6[9] / 6 + meet7[9]/7) / ( + meet0[9] + meet1[9] + meet2[9] + meet3[9] + meet4[9] + meet5[9] + meet6[9] + meet7[9]) + + if len(groups) == 10: + name = "leggenraceage" + if len(groups) == 11: + name = "legurbdietedu" + if len(groups) == 12: + name = "legphotoaudioage" + if len(groups) == 13: + name = "legracedietphoto" + if len(groups)== 14: + name = "legageeduaudio" + if len(groups) == 15: + name = "leggendietage" + if len(groups) == 16: + name = "legraceeduaudio" + if len(groups) == 17: + name = "legageurbphoto" + + filename = name + '.csv' + #filename = ('meetsvsdiv(alloc = photo.csv') with open(filename, 'w', newline='') as csvfile: writer = csv.writer(csvfile) - writer.writerow(["round div_mean", "total_div_mean", "meet1"]) + writer.writerow(["round div_mean", "total_div_mean", "meetval"]) for j in range(10): # writer.writerow([1,2,3,4]) - writer.writerow([final[j], div_mean[j], meet1[j]]) - + writer.writerow([final[j], div_mean[j], meetval]) + """ # Create AllocatorResult from sample max and return return AllocatorResult(ensemble=allocation_sample_max) @@ -357,9 +390,6 @@ def evaluate_demographics(temp_allocations, m_data): table = temp_allocations[table_no] - print("far") - - print(table) table_data = {} for index in table: @@ -388,13 +418,13 @@ def evaluate_demographics(temp_allocations, ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) - print("epis") + return table_distances def calculate_ideal_balance(cats_diverse, m_data, people): - print("prob") + ideal_balance = {} for demog in cats_diverse: counts = [0] * len(cats_diverse[demog]) @@ -404,5 +434,4 @@ def calculate_ideal_balance(cats_diverse, counts[i] += 1 ideal_balance[demog] = [count / m_data for count in counts] - print("ideal_balance") return ideal_balance \ No newline at end of file From b6ca5f94661ae164120c556d1962c45e5fcc79cc Mon Sep 17 00:00:00 2001 From: Matthew Cowie Date: Mon, 11 May 2026 11:06:56 +0100 Subject: [PATCH 07/26] newest version --- groupselect/algorithms/__init__.py | 2 +- groupselect/allocate_pandas.py | 1 + groupselect/field_mode.py | 8 +++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/groupselect/algorithms/__init__.py b/groupselect/algorithms/__init__.py index a9f788a..d7af5ae 100644 --- a/groupselect/algorithms/__init__.py +++ b/groupselect/algorithms/__init__.py @@ -3,12 +3,12 @@ from groupselect.algorithms.algorithm_legacy import algorithm_legacy - # Define Enum data type for different algorithms @unique class Algorithm(Enum): Legacy = 0 dream = 1 + Heuristic = 2 # Heuristic = 1 # TODO: Add heuristic algorithm # Define functions of algorithms. diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index 5ef3cd3..7f0bbc5 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -21,6 +21,7 @@ def allocate_pandas(participants: pd.DataFrame, settings: None | dict = None, return_full: bool = False) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame, AllocatorResult]: # Check that the dataframe index is unique. + print("enough") if not participants.index.is_unique: raise Exception('Index of dataframe must be unique.') diff --git a/groupselect/field_mode.py b/groupselect/field_mode.py index afde5ec..8bd989f 100644 --- a/groupselect/field_mode.py +++ b/groupselect/field_mode.py @@ -4,6 +4,8 @@ @unique class FieldMode(Enum): Ignore = 0 - Diversify = 1 - Cluster = 2 - Keep = 3 + Diversify_1 = 1 + Diversify_2 = 2 + Diversify_3 = 3 + Cluster = 4 + Keep = 5 From c40187f3818a50ef27575169b1c407ed0cf3f5c3 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Wed, 20 May 2026 13:50:15 +0200 Subject: [PATCH 08/26] Added metric calclations. Added functions to allocation ensemble class for calculating meeting and diversity metrics. --- groupselect/allocation.py | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/groupselect/allocation.py b/groupselect/allocation.py index f91bc02..7c240f5 100644 --- a/groupselect/allocation.py +++ b/groupselect/allocation.py @@ -1,3 +1,11 @@ +from collections import Counter +from itertools import combinations +from math import comb + +import numpy as np +import pandas as pd + + class ParticipantGroup(list[int]): pass @@ -7,6 +15,60 @@ class Allocation(list[ParticipantGroup]): class AllocationEnsemble(list[Allocation]): + def get_pids(self): + return { + p_id + for allocation in self + for group in allocation + for p_id in group + if p_id==p_id + } + + def calc_total_number_pairs(self): + pids = self.get_pids() + return comb(len(pids), 2) + + def calc_pair_counts(self) -> Counter: + pair_counts = Counter() + for allocation in self: + for group in allocation: + for pair in combinations(group, 2): + pair_counts[frozenset(pair)] += 1 + return pair_counts + + def calc_pair_occurrences(self) -> Counter: + # Calculate occurrences from pair counter. + pair_counts = self.calc_pair_counts() + occurrences = Counter(pair_counts.values()) + + # Add entry on non-observed pairs. + total_pairs = self.calc_total_number_pairs() + observed_pairs = len(pair_counts) + occurrences[0] = total_pairs - observed_pairs + + return occurrences + + def calc_meeting_rel_score(self) -> float: + occurrences = self.calc_pair_occurrences() + return 1 - occurrences[0] / sum(occurrences.values()) + + def calc_meeting_norm_score(self) -> float: + occurrences = self.calc_pair_occurrences() + rel_score = sum(v for k, v in occurrences.items() if k) + + number_pairs_per_allocation = [ + sum(comb(len(group), 2) for group in allocation) + for allocation in self + ] + min_score = max(number_pairs_per_allocation) + max_score = sum(number_pairs_per_allocation) + + return ( + ((rel_score - min_score) / (max_score - min_score)) + if max_score > min_score else + 1.0 + ) + def calc_n_meetings_alo(self) -> int: return sum( len(p_stats) @@ -36,6 +98,44 @@ def calc_meetings(self) -> dict[int, dict[int, int]]: return meetings + def _calc_diversity_score_single_field(self, values_field: np.array): + values, inverse = np.unique(values_field, return_inverse=True) + + counts_full = np.bincount(inverse) + share_full = counts_full / len(values_field) + + ret = 0.0 + for allocation in self: + for group in allocation: + group_codes = inverse[group] + + counts_group = np.bincount( + group_codes, + minlength=len(values), + ) + + share_group = counts_group / len(group) + + ret += np.abs(share_full - share_group).sum() + ret /= len(allocation) + + return ret / len(self) + + def calc_diversity_score(self, participants_data: np.ndarray[int] | pd.DataFrame) -> float: + if isinstance(participants_data, pd.DataFrame): + participants_data = ( + participants_data + .astype("category") + .apply(lambda col: col.cat.codes) + .to_numpy() + ) + + # Sum over fields. + return sum( + self._calc_diversity_score_single_field(col) + for col in participants_data.T + ) + class AllocatorResult: def __init__(self, ensemble: None | AllocationEnsemble = None): From 961ac10698a5255354a4eee63cf8658ec157ab27 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Wed, 20 May 2026 13:53:13 +0200 Subject: [PATCH 09/26] Harmonising algorithm names. --- groupselect/algorithms/__init__.py | 4 ++-- .../{algorithm_heuristic.py => algorithm_hermes.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename groupselect/algorithms/{algorithm_heuristic.py => algorithm_hermes.py} (99%) diff --git a/groupselect/algorithms/__init__.py b/groupselect/algorithms/__init__.py index d7af5ae..5351c4d 100644 --- a/groupselect/algorithms/__init__.py +++ b/groupselect/algorithms/__init__.py @@ -7,8 +7,8 @@ @unique class Algorithm(Enum): Legacy = 0 - dream = 1 - Heuristic = 2 + DREAM = 1 + HERMES = 2 # Heuristic = 1 # TODO: Add heuristic algorithm # Define functions of algorithms. diff --git a/groupselect/algorithms/algorithm_heuristic.py b/groupselect/algorithms/algorithm_hermes.py similarity index 99% rename from groupselect/algorithms/algorithm_heuristic.py rename to groupselect/algorithms/algorithm_hermes.py index 23b77a3..3b0abee 100644 --- a/groupselect/algorithms/algorithm_heuristic.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -13,7 +13,7 @@ from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult -def algorithm_heuristic(participants: np.ndarray[int], +def algorithm_hermes(participants: np.ndarray[int], fields: dict[int, FieldMode], groups: list[(int, int)], manuals: dict[int, int], From c43fdad85c49639471e064f31a455f57b248a00d Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Wed, 20 May 2026 18:16:40 +0200 Subject: [PATCH 10/26] Improved settings for pareto probabilities. --- groupselect/algorithms/algorithm_hermes.py | 70 +++++----------------- groupselect/allocate_pandas.py | 8 ++- 2 files changed, 21 insertions(+), 57 deletions(-) diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 3b0abee..bb5e93f 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -20,62 +20,20 @@ def algorithm_hermes(participants: np.ndarray[int], progress_func: None | Callable = None, n_attempts: int = 3, seed: None | int = None, - prob1: float = -1, - prob2: float = -1, - prob3: float = -1, - prob4: float = -1, - prob5: float = -1, - prob6: float = -1, - prob7: float = -1, - prob8: float = -1, - prob9: float = -1, - prob10: float = -1, - prob11: float = -1, - prob12: float = -1, - prob13: float = -1, - prob14: float = -1, - prob15: float = -1, - prob16: float = -1, - prob17: float = -1, - prob18: float = -1, - prob19: float = -1, - prob20: float = -1, - ): - - - - pareto_prob = [0] * 20 - pareto_prob[0] = prob1/20 - - pareto_prob[1] = prob2/20 - - pareto_prob[2] = prob3/20 - - pareto_prob[3] = prob4/20 - - pareto_prob[4] = prob5/20 - pareto_prob[5] = prob6/20 - pareto_prob[6] = prob7/20 - pareto_prob[7] = prob8/20 - pareto_prob[8] = prob9/20 - pareto_prob[9] = prob10/20 - pareto_prob[10] = prob11/20 - pareto_prob[11] = prob12/20 - pareto_prob[12] = prob13/20 - pareto_prob[13] = prob14/20 - pareto_prob[14] = prob15/20 - pareto_prob[15] = prob16/20 - pareto_prob[16] = prob17/20 - pareto_prob[17] = prob18/20 - pareto_prob[18] = prob19/20 - pareto_prob[19] = prob20/20 - - pareto_probs = {} - i = 0 - for k, v in fields.items(): - if v == FieldMode.Diversify_1: - pareto_probs[k] = pareto_prob[i] - i = i + 1 + pareto_probs: dict[int, float] = None, +): + pareto_probs = pareto_probs or {} + for field_id in fields: + if fields[field_id]==FieldMode.Diversify_1: + if field_id not in pareto_probs: + raise Exception(f"Algorithm HERMES requires pareto probability to be passed for diversity fields, but " + f"none found for field {field_id}.") + if not (isinstance(pareto_probs[field_id], float) and pareto_probs[field_id] >= 0.0): + raise Exception(f"Pareto probability must be positive float but found: {pareto_probs[field_id]}") + + # Divide all by factor of 20. + for field_id in pareto_probs: + pareto_probs[field_id] /= 20.0 nallocations = len(groups) progress_bar = progress_func diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index 7f0bbc5..e1c1b9f 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -50,6 +50,12 @@ def allocate_pandas(participants: pd.DataFrame, participants_codes.columns.tolist().index(k): v for k, v in fields.items() } + settings_numpy = settings.copy() + if "pareto_probs" in settings: + settings_numpy["pareto_probs"] = { + participants_codes.columns.tolist().index(k): v + for k, v in settings_numpy["pareto_probs"].items() + } # Run allocation with numpy arrays. result: AllocatorResult = allocate_numpy( @@ -59,7 +65,7 @@ def allocate_pandas(participants: pd.DataFrame, manuals=manuals, algorithm=algorithm, progress_func=progress_func, - settings=settings, + settings=settings_numpy, ) # Generate pandas dataframe based on numpy results and return. From 712c8fcd971fbfa501f3430590a47d88d3d8660f Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Wed, 20 May 2026 18:36:03 +0200 Subject: [PATCH 11/26] Dropping field modes Diversify_{2,3}. * This commit drops obsolete diversify modes. --- groupselect/algorithms/__init__.py | 1 - groupselect/algorithms/algorithm_hermes.py | 6 +++--- groupselect/field_mode.py | 8 +++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/groupselect/algorithms/__init__.py b/groupselect/algorithms/__init__.py index 5351c4d..c223247 100644 --- a/groupselect/algorithms/__init__.py +++ b/groupselect/algorithms/__init__.py @@ -9,7 +9,6 @@ class Algorithm(Enum): Legacy = 0 DREAM = 1 HERMES = 2 - # Heuristic = 1 # TODO: Add heuristic algorithm # Define functions of algorithms. algorithm_funcs = { diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index bb5e93f..089a383 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -24,7 +24,7 @@ def algorithm_hermes(participants: np.ndarray[int], ): pareto_probs = pareto_probs or {} for field_id in fields: - if fields[field_id]==FieldMode.Diversify_1: + if fields[field_id]==FieldMode.Diversify: if field_id not in pareto_probs: raise Exception(f"Algorithm HERMES requires pareto probability to be passed for diversity fields, but " f"none found for field {field_id}.") @@ -47,7 +47,7 @@ def algorithm_hermes(participants: np.ndarray[int], order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1]#or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] @@ -990,7 +990,7 @@ def evaluate_actions(ideal_dist, actions_for_label = [] if table_dist[index] > ideal_dist[index]: for a, b in zip(table_discrepancies, cat_labels): - if diversity_val == FieldMode.Diversify_1 and a < threshold: + if diversity_val == FieldMode.Diversify and a < threshold: actions_for_label.append(b) #elif diversity_val == FieldMode.Diversify_2 and a < -0.2: # actions_for_label.append(b) diff --git a/groupselect/field_mode.py b/groupselect/field_mode.py index 8bd989f..afde5ec 100644 --- a/groupselect/field_mode.py +++ b/groupselect/field_mode.py @@ -4,8 +4,6 @@ @unique class FieldMode(Enum): Ignore = 0 - Diversify_1 = 1 - Diversify_2 = 2 - Diversify_3 = 3 - Cluster = 4 - Keep = 5 + Diversify = 1 + Cluster = 2 + Keep = 3 From 0aaa34300190b2bbaf975e60e7e0edddca794008 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Wed, 20 May 2026 18:39:42 +0200 Subject: [PATCH 12/26] Corrected pareto prob parameter range. --- groupselect/algorithms/algorithm_hermes.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 089a383..a717541 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -28,12 +28,8 @@ def algorithm_hermes(participants: np.ndarray[int], if field_id not in pareto_probs: raise Exception(f"Algorithm HERMES requires pareto probability to be passed for diversity fields, but " f"none found for field {field_id}.") - if not (isinstance(pareto_probs[field_id], float) and pareto_probs[field_id] >= 0.0): - raise Exception(f"Pareto probability must be positive float but found: {pareto_probs[field_id]}") - - # Divide all by factor of 20. - for field_id in pareto_probs: - pareto_probs[field_id] /= 20.0 + if not (isinstance(pareto_probs[field_id], float) and 0.0 <= pareto_probs[field_id] <= 0.5): + raise Exception(f"Pareto probability must be float in range [0.0, 0.5] but found: {pareto_probs[field_id]}") nallocations = len(groups) progress_bar = progress_func @@ -1007,5 +1003,3 @@ def evaluate_actions(ideal_dist, return actions - - From 8407f7f508d673f60f2f2a411f6cc456e8b9c7e1 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:07:34 +0200 Subject: [PATCH 13/26] Cleaned up code. --- groupselect/algorithms/algorithm_dream.py | 132 +------ groupselect/algorithms/algorithm_hermes.py | 382 +-------------------- groupselect/algorithms/algorithm_legacy.py | 212 +----------- groupselect/allocate_pandas.py | 7 +- groupselect/tests/test_pandas.py | 22 +- 5 files changed, 12 insertions(+), 743 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 51d39fd..6dca641 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -27,7 +27,7 @@ def algorithm_dream(participants: np.ndarray[int], seats = value order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] + order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] if len(order_cluster ) >=1: @@ -62,15 +62,6 @@ def algorithm_dream(participants: np.ndarray[int], pareto_prob = 0.5 - - ''' - cluster_tables: int, - m_data: int, - pareto_prob: float, - swap_rounds: int, - - progress_bar: any == None''' - seats = math.ceil(m_data /tables) previous_meetings = {} @@ -82,18 +73,12 @@ def algorithm_dream(participants: np.ndarray[int], if (nallocations < 1): raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") - # if len(tables)>1: do this bit - peopledata_vals_used = [{} for i in range(m_data)] for i in range(m_data): for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - # order_cluster_dict = get_field_cluster_dict() - # order_diverse_dict = get_field_diverse_dict() - - 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.") @@ -115,49 +100,8 @@ def algorithm_dream(participants: np.ndarray[int], n_results = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_prob, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random) allocation_results = n_results - ''' allocations = [] - for result in n_results[0]: - allocations.append(n_results[0][result]) - - allocation_group_outcome = allocations - - d_mult = m_data// (tables**2) - L_R = ((tables**2) * 8.5 * d_mult * (d_mult-1)) + d_mult * (m_data % (tables**2)) - min_duplicates = max(0, L_R) - - optimal_pairs = 0 - for table in allocations[0]: - n = len(table) - optimal_pairs += n * (n - 1) // 2 - - total_possible_pairs = 0 - for round_no in range(nallocations): - if round_no == 0: - # no restrictions on repeating pairs - total_possible_pairs += optimal_pairs - else: - total_possible_pairs += optimal_pairs - min_duplicates - # calculate total pairs in sample - total_pairs = m_data * (m_data - 1) // 2 - - if 0 in n_results[1][nallocations - 1]: - allocation_group_links_pp = (total_pairs - n_results[2][nallocations - 1][0]) / m_data - else: - # all pairs have met - allocation_group_links_pp = total_pairs - - # maximum links from round 0 to 1 are a function of table size and number of tables - allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data - - ''' # Select the sample with maximum number of meetings. - # final_results1: list[AllocationEnsemble] = [ - # AllocationEnsemble() - # for _ in range(1) - # ] - - final_results2 = max(allocation_results) return AllocatorResult(final_results2) @@ -178,13 +122,6 @@ def allocate(tables, manuals, random): n_rounds = nallocations - pre_meeting_dist = {} - post_meeting_dist = {} - new_meetings_in_round = {} - pre_balance = {} - post_balance = {} - - allocations_list = {} for i in range(m_data): for j in range( i +1, m_data): @@ -192,7 +129,6 @@ def allocate(tables, if pair not in previous_meetings: previous_meetings[pair] = 0 - # allocation_attempts : AllocationEnsemble = AllocationEnsemble() allocation_attempts: list[AllocationEnsemble] = [ AllocationEnsemble() for _ in range(1) @@ -223,9 +159,6 @@ def allocate(tables, template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] - meetings_previous_round = previous_meetings.copy() - - '''round_assign_pre, round_assign_swap, meetings_pre, ''' allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, pareto_prob, random) @@ -237,40 +170,6 @@ def allocate(tables, for n, ensemble in enumerate(allocation_attempts): ensemble.append(allocation) - '''pre_occurences = {} - for value in meetings_pre.values(): - pre_occurences[value] = pre_occurences.get(value, 0) + 1 - pre_meeting_dist[round_no] = pre_occurences - occurences = {} - for value in previous_meetings.values(): - occurences[value] = occurences.get(value, 0) + 1 - post_meeting_dist[round_no] = occurences - - new_meetings = {} - for pair in previous_meetings: - if previous_meetings[pair]-meetings_previous_round[pair] == 1: - new_meetings[pair] = previous_meetings[pair] - round_meetings = {} - for value in new_meetings.values(): - round_meetings[value] = round_meetings.get(value, 0) + 1 - new_meetings_in_round[round_no] = round_meetings - - pre_demog_evaluations = {} - for index, table in enumerate(round_assign_pre): - pre_demog_evaluations[index] = {} - pre_demog_evaluations[index] = evaluate_demographics(round_assign_pre, index, peopledata_vals_used, order_diverse_dict, m_data)[0] - pre_balance[round_no] = averages_from_evals(pre_demog_evaluations) - post_demog_evaluations = {} - for index, table in enumerate(round_assign_swap): - post_demog_evaluations[index] = {} - post_demog_evaluations[index] = evaluate_demographics(round_assign_swap, index, peopledata_vals_used, order_diverse_dict, m_data)[0] - post_balance[round_no] = averages_from_evals(post_demog_evaluations) - - allocations_list[round_no] = round_assign_swap - - allocations_list, pre_meeting_dist, post_meeting_dist, new_meetings_in_round, pre_balance, post_balance, - ''' - return allocation_attempts @@ -292,21 +191,6 @@ def calculate_ideal_balance(cats_diverse, return ideal_balance -def averages_from_evals(evaluations: dict): - values_per_key = {} - - for nested_dict in evaluations.values(): - for key, value in nested_dict.items(): - if key not in values_per_key: - values_per_key[key] = [] - values_per_key[key].append(value) - averages = {} - for key in values_per_key: - lst = values_per_key[key] - averages[key] = sum(lst) / len(lst) - return averages - - def run_round(template, n_swap_loops, seats, @@ -384,8 +268,6 @@ def run_round(template, pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) - raw_meetings = previous_meetings.copy() - for sublist in pareto_allocations: for i in range(len(sublist)): @@ -396,21 +278,11 @@ def run_round(template, previous_meetings[pair] += 1 - for sublist in allocations: - for i in range(len(sublist)): - for j in range(i + 1, len(sublist)): - pair = (min(sublist[i], sublist[j]), - max(sublist[i], sublist[j])) - # Increment count for the pair in the dictionary - raw_meetings[pair] += 1 - this_alloc = Allocation( ParticipantGroup(list) for list in pareto_allocations ) - '''allocations, pareto_allocations, raw_meetings, ''' - return this_alloc @@ -706,5 +578,3 @@ def evaluate_actions(ideal_dist, actions_for_label.append(b) actions[label] = actions_for_label return actions - - diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index a717541..95eae44 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -1,10 +1,8 @@ import copy import math -import statistics from itertools import product from typing import Callable -import csv import numpy as np @@ -38,15 +36,9 @@ def algorithm_hermes(participants: np.ndarray[int], tables = value[0] seats = value - - - - order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] - - X = participants[:, order_cluster] X = np.unique(X) @@ -54,21 +46,13 @@ def algorithm_hermes(participants: np.ndarray[int], Y = participants[:, order_diverse] lister = [None] * len(order_diverse) - for i in range(0, len(order_diverse)): - lister[i] = [int(item[i]) for item in Y] lister[i] = np.unique(lister[i]) lister[i] = lister[i].tolist() - - - - - Y = np.unique(Y) - if len(order_cluster ) >=1: val_cluster = X[len(X)-1] else: @@ -85,14 +69,6 @@ def algorithm_hermes(participants: np.ndarray[int], m_data = participants.shape[0] - ''' - cluster_tables: int, - m_data: int, - pareto_prob: float, - swap_rounds: int, - - progress_bar: any == None''' - seats = math.ceil(m_data /tables) previous_meetings = {} try: @@ -103,18 +79,12 @@ def algorithm_hermes(participants: np.ndarray[int], if (nallocations < 1): raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") - # if len(tables)>1: do this bit - peopledata_vals_used = [{} for i in range(m_data)] for i in range(m_data): for j in order_cluster + order_diverse: peopledata_vals_used[i][j] = int(participants[i][j]) - # order_cluster_dict = get_field_cluster_dict() - # order_diverse_dict = get_field_diverse_dict() - - 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.") @@ -135,172 +105,10 @@ def algorithm_hermes(participants: np.ndarray[int], raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") - n_results, meet0, meet1, meet2, meet3, meet4, meet5 = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) - - - allocation_results = n_results - #print(meet1) - #meetval = (meet1[9]+(meet2[9]/2)+meet3[9]/3 +meet4[9]/4+meet5[9]/5)/ (meet0[9]+meet1[9]+meet2[9]+meet3[9]+meet4[9]+meet5[9]) - #print(meetval) - allocations = [] - #for result in n_results[0]: - # allocations.append(n_results[0][result]) - print("6") - #allocation_group_outcome = allocations - ''' - d_mult = m_data// (tables**2) - L_R = ((tables**2) * 8.5 * d_mult * (d_mult-1)) + d_mult * (m_data % (tables**2)) - min_duplicates = max(0, L_R) - - optimal_pairs = 0 - for table in allocations[0]: - n = len(table) - optimal_pairs += n * (n - 1) // 2 - - total_possible_pairs = 0 - for round_no in range(nallocations): - if round_no == 0: - # no restrictions on repeating pairs - total_possible_pairs += optimal_pairs - else: - total_possible_pairs += optimal_pairs - min_duplicates - # calculate total pairs in sample - total_pairs = m_data * (m_data - 1) // 2 - - if 0 in n_results[1][nallocations - 1]: - allocation_group_links_pp = (total_pairs - n_results[2][nallocations - 1][0]) / m_data - else: - # all pairs have met - allocation_group_links_pp = total_pairs - ''' - # maximum links from round 0 to 1 are a function of table size and number of tables - #allocation_group_links_pp_max = min(total_pairs, total_possible_pairs) / m_data - - - - # Select the sample with maximum number of meetings. - # final_results1: list[AllocationEnsemble] = [ - # AllocationEnsemble() - # for _ in range(1) - # ] - distance = {} + allocation_results = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) - #for i in range (0, tables): - # distance[i] = evaluate_demographics(i, peopledata_vals_used, order_diverse_dict, m_data, fields) - - #distances = evaluate_demographics() - #print(distances, "di - - final = [0] * nallocations - div_mean = [0] * nallocations - minimum = [0] * nallocations - maximum = [0] * nallocations final_results2 = max(allocation_results) - """ - if val_cluster == '': - it = 8 - else : - it = 7 - for j in range(nallocations): - - for i in range (0, tables): - distance[i] = evaluate_demographics(allocation_results[0][j], i, peopledata_vals_used, order_diverse_dict, m_data, fields, pareto_probs) - - this = [y for x,y in distance.items()] - this2 = [x for x,y in this] - this3 = [x[it] for x in this2] - #this4 = [x[7] for x in this2] - #this5 = [x[6] for x in this2] - # print("mu") - #for i in range (0, len(distance)): - #if (j == 983): - #print(this3, "this3") - # print("phi") - #final3 = statistics.mean(this3) - #final4 = statistics.mean(this4) - #final5 = statistics.mean(this5) - #final[j] = statistics.mean([final3, final4, final5]) - final[j] = statistics.mean(this3) - #maximum[j] = max(this3) - #minimum[j] = min(this3) - #print(final[0:j+1]) - # print("omega") - div_mean[j] = statistics.mean(final[0:j+1]) - - #print(final[j]) - #print(maximum[j]) - #print(minimum[j]) - #meet_mean = statistics.mean(prev_meet_mean) - #print("delta") - #if pareto_prob == 0.275: - # rank = 2 - #elif pareto_prob == 0.5: - # rank = 1 - #else: - # rank = 3 - print(prob1) - if pareto_prob[0] == 0.5: - if nallocations == 10: - name = "dreamgenraceage" - if nallocations == 11: - name = "dreamurbdietedu" - if nallocations == 12: - name = "dreamphotoaudioage" - if nallocations == 13: - name = "dreamracedietphoto" - if nallocations == 14: - name = "dreamageeduaudio" - if nallocations == 15: - name = "dreamgendietage" - if nallocations == 16: - name = "dreamraceeduaudio" - if nallocations == 17: - name = "dreamageurbphoto" - if pareto_prob[0] == 0.25: - if nallocations == 10: - name = "heurgenraceage" - if nallocations == 11: - name = "heururbdietedu" - if nallocations == 12: - name = "heurphotoaudioage" - if nallocations == 13: - name = "heurracedietphoto" - if nallocations == 14: - name = "heurageeduaudio" - if nallocations == 15: - name = "heurgendietage" - if nallocations == 16: - name = "heurraceeduaudio" - if nallocations == 17: - name = "heurageurbphoto" - if pareto_prob[0] != pareto_prob[1]: - if nallocations == 10: - name = "spreadgenraceage" - if nallocations == 11: - name = "spreadurbdietedu" - if nallocations == 12: - name = "spreadphotoaudioage" - if nallocations == 13: - name = "spreadracedietphoto" - if nallocations == 14: - name = "spreadageeduaudio" - if nallocations == 15: - name = "spreadgendietage" - if nallocations == 16: - name = "spreadraceeduaudio" - if nallocations == 17: - name = "spreadageurbphoto" - - #filename = ('mutli cats, rank =' + rank.__str__() + ')' + name + '.csv') - filename = (name + ".csv") - with open(filename, 'w', newline='') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(["round div_mean","total_div_mean", "meet0", "meet1", "meet2", "meet3"]) - for j in range(0, 10): - # writer.writerow([1,2,3,4]) - writer.writerow([final[j], div_mean[j], meet0[j], meet1[j], meet2[j], meet3[j], meetval]) - """ - print(meet0, meet1, meet2, meet3, meet4, meet5, "meetsdud") + return AllocatorResult(final_results2) def allocate(tables, @@ -321,37 +129,12 @@ def allocate(tables, fields): n_rounds = nallocations - previous_meetings_max = [0] * nallocations - previous_meetings_min = [0] * nallocations - round_meetings_mean = [0] * nallocations - total_meetings_mean = [0] * nallocations - - meet0 = [0] * nallocations - meet1 = [0] * nallocations - meet2 = [0] * nallocations - meet3 = [0] * nallocations - meet4 = [0] * nallocations - meet5 = [0] * nallocations - meet6 = [0] * nallocations - meet7 = [0] * nallocations - meet8 = [0] * nallocations - meet9 = [0] * nallocations - meet10 = [0] * nallocations - pre_meeting_dist = {} - post_meeting_dist = {} - new_meetings_in_round = {} - pre_balance = {} - post_balance = {} - - allocations_list = {} - for i in range(m_data): for j in range( i +1, m_data): pair = (i ,j) if pair not in previous_meetings: previous_meetings[pair] = 0 - # allocation_attempts : AllocationEnsemble = AllocationEnsemble() allocation_attempts: list[AllocationEnsemble] = [ AllocationEnsemble() for _ in range(1) @@ -382,9 +165,6 @@ def allocate(tables, template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] - meetings_previous_round = previous_meetings.copy() - - '''round_assign_pre, round_assign_swap, meetings_pre, ''' allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, pareto_probs, random, fields) @@ -396,100 +176,7 @@ def allocate(tables, for n, ensemble in enumerate(allocation_attempts): ensemble.append(allocation) - '''pre_occurences = {} - for value in meetings_pre.values(): - pre_occurences[value] = pre_occurences.get(value, 0) + 1 - pre_meeting_dist[round_no] = pre_occurences - occurences = {} - for value in previous_meetings.values(): - occurences[value] = occurences.get(value, 0) + 1 - post_meeting_dist[round_no] = occurences - - new_meetings = {} - for pair in previous_meetings: - if previous_meetings[pair]-meetings_previous_round[pair] == 1: - new_meetings[pair] = previous_meetings[pair] - round_meetings = {} - for value in new_meetings.values(): - round_meetings[value] = round_meetings.get(value, 0) + 1 - new_meetings_in_round[round_no] = round_meetings - - pre_demog_evaluations = {} - for index, table in enumerate(round_assign_pre): - pre_demog_evaluations[index] = {} - pre_demog_evaluations[index] = evaluate_demographics(round_assign_pre, index, peopledata_vals_used, order_diverse_dict, m_data)[0] - pre_balance[round_no] = averages_from_evals(pre_demog_evaluations) - post_demog_evaluations = {} - for index, table in enumerate(round_assign_swap): - post_demog_evaluations[index] = {} - post_demog_evaluations[index] = evaluate_demographics(round_assign_swap, index, peopledata_vals_used, order_diverse_dict, m_data)[0] - post_balance[round_no] = averages_from_evals(post_demog_evaluations) - - allocations_list[round_no] = round_assign_swap - - allocations_list, pre_meeting_dist, post_meeting_dist, new_meetings_in_round, pre_balance, post_balance, - ''' - - # print(allocation) - # print(previous_meetings) - - values = [0] * 5 - - #for key, value in previous_meetings.items(): - # i = 0 - # print("issue") - # for table in allocation: - # - # print(i) - # for pid in table: - # if pid in key: - # print(key) - # values[i] = values[i] + value - # i = i + 1 - #print(values, "values") - - - #for table in allocation: - # for pid in table: - # pid_meets = (pid in x for x in previous_meetings.keys()) - # print(pid_meets) - # break - #break - - print(previous_meetings, "meeter1") - meet0[round_no] = sum(x == 0 for x in previous_meetings.values()) - meet1[round_no] = sum(x == 1 for x in previous_meetings.values()) - meet2[round_no] = sum(x == 2 for x in previous_meetings.values()) - meet3[round_no] = sum(x == 3 for x in previous_meetings.values()) - meet4[round_no] = sum(x == 4 for x in previous_meetings.values()) - meet5[round_no] = sum(x == 5 for x in previous_meetings.values()) - #meet6[round_no] = sum(x == 6 for x in previous_meetings.values()) - #meet7[round_no] = sum(x == 7 for x in previous_meetings.values()) - #meet8[round_no] = sum(x == 8 for x in previous_meetings.values()) - #meet9[round_no] = sum(x == 9 for x in previous_meetings.values()) - #meet10[round_no] = sum(x == 10 for x in previous_meetings.values()) - - #previous_meetings_max[round_no] = max(previous_meetings.values()) - #previous_meetings_min[round_no] = min(previous_meetings.values()) - #round_meetings_mean[round_no] = statistics.mean(previous_meetings.values()) - #print(round_meetings_mean[0:round_no+1]) - #print(statistics.mean(round_meetings_mean[0:round_no+1])) - #total_meetings_mean[round_no] = statistics.mean(round_meetings_mean[0:round_no+1]) - - #print(round_meetings_mean) - # if pareto_prob == 0.3: - # filename = '10alloc2.csv' - # elif pareto_prob == 0.5: - # filename = '10alloc.csv' - # else: - # filename = '10alloc3.csv' - - # with open(filename, 'w', newline='') as csvfile: - # writer = csv.writer(csvfile) - # writer.writerow(['round_no', 'meet0', 'meet1', 'meet2', 'meet3', 'meet4', 'meet5', 'meet6', 'meet7', 'meet8', 'meet9', 'meet10']) - # for i in range(n_rounds): - # writer.writerow([i, meet0[i], meet1[i], meet2[i], meet3[i], meet4[i], meet5[i], meet6[i], meet7[i], meet8[i], meet9[i], meet10[i]]) - return allocation_attempts, meet0, meet1, meet2, meet3, meet4, meet5 + return allocation_attempts def calculate_ideal_balance(cats_diverse, @@ -508,21 +195,6 @@ def calculate_ideal_balance(cats_diverse, return ideal_balance -def averages_from_evals(evaluations: dict): - values_per_key = {} - - for nested_dict in evaluations.values(): - for key, value in nested_dict.items(): - if key not in values_per_key: - values_per_key[key] = [] - values_per_key[key].append(value) - averages = {} - for key in values_per_key: - lst = values_per_key[key] - averages[key] = sum(lst) / len(lst) - return averages - - def run_round(template, n_swap_loops, seats, @@ -600,8 +272,6 @@ def run_round(template, pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, m_data, pareto_probs, random, fields) - raw_meetings = previous_meetings.copy() - for sublist in pareto_allocations: for i in range(len(sublist)): @@ -612,21 +282,11 @@ def run_round(template, previous_meetings[pair] += 1 - for sublist in allocations: - for i in range(len(sublist)): - for j in range(i + 1, len(sublist)): - pair = (min(sublist[i], sublist[j]), - max(sublist[i], sublist[j])) - # Increment count for the pair in the dictionary - raw_meetings[pair] += 1 - this_alloc = Allocation( ParticipantGroup(list) for list in pareto_allocations ) - '''allocations, pareto_allocations, raw_meetings, ''' - return this_alloc @@ -692,17 +352,6 @@ def pareto_swaps(shuffled_pids, for index, demog in enumerate(pareto_profile): if pid_info[demog] in pareto_profile[demog][profile[index]]: - - #print(fields[list(cats_diverse.keys())[list(cats_diverse.values()).index()]]) - #diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cats_labels)]] - diversity_val = fields[demog] - #if diversity_val == FieldMode.Diversify_3: - # pareto_score += 1 - # rank3_score += 1 - # elif diversity_val == FieldMode.Diversify_2: - # pareto_score += 1 - # rank2_score += 1 - #else: pareto_score += 1 demog_pareto[demog-1] = True @@ -941,9 +590,6 @@ def evaluate_demographics(temp_allocations, table_distances = {} table_length = len(table) - i = 0 - - for demog in cats_diverse: counts = [0] * len(cats_diverse[demog]) for person in table_data.values(): @@ -973,33 +619,15 @@ def evaluate_actions(ideal_dist, actions = {} - #print(list(mydict.keys())[list(mydict.values()).index(16)]) - #mydict.keys()[mydict.values().index(16)] - diversity_val = fields[list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)]] - lister = list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)] - #top_prob = max(pareto_probs) threshold = -0.5 + pareto_probs[lister] - #k = fields[cats_diverse[cat_labels]] for index, label in enumerate(cat_labels): actions_for_label = [] if table_dist[index] > ideal_dist[index]: for a, b in zip(table_discrepancies, cat_labels): - if diversity_val == FieldMode.Diversify and a < threshold: - actions_for_label.append(b) - #elif diversity_val == FieldMode.Diversify_2 and a < -0.2: - # actions_for_label.append(b) - #elif diversity_val == FieldMode.Diversify_3 and a < -0.7: - # actions_for_label.append(b) + if a < threshold: + actions_for_label.append(b) actions[label] = actions_for_label - - - # for i in range (0,len(actions)): - # if len(actions[i]) != 0: - # for j in range (0,len(actions[i])): - # actions[i][j] = math.ceil(actions[i][j] * 0.5) - - return actions diff --git a/groupselect/algorithms/algorithm_legacy.py b/groupselect/algorithms/algorithm_legacy.py index cc24f00..ec68444 100644 --- a/groupselect/algorithms/algorithm_legacy.py +++ b/groupselect/algorithms/algorithm_legacy.py @@ -1,5 +1,3 @@ -import csv -import statistics from typing import Callable import numpy as np @@ -17,36 +15,6 @@ def algorithm_legacy(participants: np.ndarray[int], n_attempts: int = 100, seed: None | int = None) -> AllocatorResult: - meet0 = [0] * 10 - meet1 = [0] * 10 - meet2 = [0] * 10 - meet3 = [0] * 10 - meet4 = [0] * 10 - meet5 = [0] * 10 - meet6 = [0] * 10 - meet7 = [0] * 10 - - - m_data = participants.shape[0] - - - peopledata_vals_used = [{} for i in range(m_data)] - order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify_1 or v == FieldMode.Diversify_2 or v == FieldMode.Diversify_3] - - lister = [None] * len(order_diverse) - Y = participants[:, order_diverse] - for i in range(0, len(order_diverse)): - lister[i] = [int(item[i]) for item in Y] - - lister[i] = np.unique(lister[i]) - lister[i] = lister[i].tolist() - order_diverse_dict = dict(zip(order_diverse, lister)) - - for i in range(m_data): - for j in order_cluster + order_diverse: - peopledata_vals_used[i][j] = int(participants[i][j]) - # Check argument: seed if not (seed is None or isinstance(seed, int)): raise Exception('Random number seed must be integer.') @@ -64,7 +32,7 @@ def algorithm_legacy(participants: np.ndarray[int], raise Exception('Argument n_attempts must be positive integer.') # Check that there is at least one diversification field defined. - if not FieldMode.Diversify_1 in fields.values(): + if not FieldMode.Diversify in fields.values(): raise Exception('Error: One diversification field required!', 'You have to set at least one field that is ' 'used to diversify people across groups.') @@ -87,9 +55,7 @@ def algorithm_legacy(participants: np.ndarray[int], for n, ensemble in enumerate(allocation_attempts): if progress_func is not None: progress_func(n) - i = 0 for n_gr, n_ppgr in groups: - i = i + 1 # Shuffle participant IDs. shuffle = list(range(len(participants))) random.shuffle(shuffle) @@ -109,49 +75,10 @@ def algorithm_legacy(participants: np.ndarray[int], for group in allocation ) - #distance = {} - #index = 0 - #for table in allocation: - # print(index) - # distance[i] = evaluate_demographics( - # allocation, index, peopledata_vals_used, order_diverse_dict, m_data) - # index += 1 - - #print(fields) - #print(allocation) - #print(participants) - - #fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify_1] - - #groups_list = [ - # g_id - # for g_id, group in enumerate(allocation) - # if len(group) <= n_ppgr - #] - field_val = [0,1] - #fielders = [] - #for field_id in fields_diversify: - # for val in field_val: - # field_value_counts = { - # g_id: _count_categories(allocation[g_id], field_id, val, participants) - # for g_id in groups_list - # } - # field_val_counts_min = min(field_value_counts.items(), key=lambda x: x[1])[1] - #groups_list = [ - # g_id - # for g_id in groups_list - # if field_value_counts[g_id] == field_val_counts_min - #] - # fielders.append(field_value_counts) - - - #print("here ", distance) - # Append allocation to ensemble. ensemble.append(allocation) # Sample `n_allocation` allocations and repeat that `n_attempts` times. - allocation_samples: list[AllocationEnsemble] = [ AllocationEnsemble() for _ in range(n_attempts) @@ -160,94 +87,12 @@ def algorithm_legacy(participants: np.ndarray[int], for sample, choice in zip(allocation_samples, random.choice(n_attempts, n_attempts)): sample.append(allocation_attempts[choice][a_id]) - # Select the sample with maximum number of meetings. allocation_sample_max = max( allocation_samples, key=lambda ensemble: ensemble.calc_n_meetings_alo(), ) - previous_meetings = {} - - - for i in range(m_data): - for j in range( i +1, m_data): - pair = (i ,j) - if pair not in previous_meetings: - previous_meetings[pair] = 0 - - """ - final = [0] * 10 - div_mean = [0] * 10 - distance = {} - print("6") - if order_cluster == []: - it = 8 - else: - it = 7 - for j in range(0, 10): - for i in range(0, 10): - distance[i] = evaluate_demographics( - allocation_sample_max[j], i, peopledata_vals_used, order_diverse_dict, m_data) - - - this = [y for x, y in distance.items()] - this3 = [x[it] for x in this] - # for i in range (0, len(distance)): - # if (j == 983): - # print(this3, "this3") - final[j] = statistics.mean(this3) - # print(final[0:j+1]) - div_mean[j] = statistics.mean(final[0:j + 1]) - print("7") - for n in range(0, 10): - for m in range (0, len(allocation_sample_max[n])): - - for i in range (0, len(allocation_sample_max[n][m])): - - pid_1 = allocation_sample_max[n][m][i] - for j in range (i+1, len(allocation_sample_max[n][m])): - pid_2 = allocation_sample_max[n][m][j] - pair = (min(pid_1,pid_2), max(pid_1,pid_2)) - previous_meetings[pair] += 1 - meet0[n] = sum(x == 0 for x in previous_meetings.values()) - meet1[n] = sum(x == 1 for x in previous_meetings.values()) - meet2[n] = sum(x == 2 for x in previous_meetings.values()) - meet3[n] = sum(x == 3 for x in previous_meetings.values()) - meet4[n] = sum(x == 4 for x in previous_meetings.values()) - meet5[n] = sum(x == 5 for x in previous_meetings.values()) - meet6[n] = sum(x == 6 for x in previous_meetings.values()) - meet7[n] = sum(x == 7 for x in previous_meetings.values()) - print("8") - meetval = (meet1[9] + (meet2[9] / 2) + meet3[9] / 3 + meet4[9] / 4 + meet5[9] / 5 + meet6[9] / 6 + meet7[9]/7) / ( - meet0[9] + meet1[9] + meet2[9] + meet3[9] + meet4[9] + meet5[9] + meet6[9] + meet7[9]) - - if len(groups) == 10: - name = "leggenraceage" - if len(groups) == 11: - name = "legurbdietedu" - if len(groups) == 12: - name = "legphotoaudioage" - if len(groups) == 13: - name = "legracedietphoto" - if len(groups)== 14: - name = "legageeduaudio" - if len(groups) == 15: - name = "leggendietage" - if len(groups) == 16: - name = "legraceeduaudio" - if len(groups) == 17: - name = "legageurbphoto" - - filename = name + '.csv' - #filename = ('meetsvsdiv(alloc = photo.csv') - with open(filename, 'w', newline='') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(["round div_mean", "total_div_mean", "meetval"]) - for j in range(10): - # writer.writerow([1,2,3,4]) - writer.writerow([final[j], div_mean[j], meetval]) - """ # Create AllocatorResult from sample max and return return AllocatorResult(ensemble=allocation_sample_max) @@ -273,7 +118,7 @@ def _allocate_legacy_once(participants: np.ndarray[int], # Split fields into clustering and diversification fields. Order participant IDs # by clustering and diversification field values. fields_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify_1] + fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify] p_ids_ordered = np.lexsort(participants[:, (fields_cluster + fields_diversify)[::-1]].T) # Loop over participants and allocate to a group. @@ -382,56 +227,3 @@ def _count_categories(group: ParticipantGroup, field_id: int, field_val: int, pa def _number_of_people_filtered(participants: np.ndarray[int], fields: dict[int, int]): return (participants.T[list(fields.keys())] == list(fields.values())).all(axis=0).sum() - -def evaluate_demographics(temp_allocations, - table_no, - people, - cats_diverse, - m_data): - table = temp_allocations[table_no] - - - table_data = {} - for index in table: - table_data[index] = people[index] - - - ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) - - table_balance = {} - table_actions = {} - table_distances = {} - table_length = len(table) - - i = 0 - - - for demog in cats_diverse: - counts = [0] * len(cats_diverse[demog]) - for person in table_data.values(): - for i, category in enumerate(cats_diverse[demog]): - if person.get(demog) == category: - counts[i] += 1 - table_balance[demog] = [count / table_length for count in counts] - - table_distances[demog] = sum([abs(x - y) for x, y in zip( - ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) - - - - return table_distances - -def calculate_ideal_balance(cats_diverse, - m_data, - people): - - ideal_balance = {} - for demog in cats_diverse: - counts = [0] * len(cats_diverse[demog]) - for row in people: - for i, category in enumerate(cats_diverse[demog]): - if row[demog] == category: - counts[i] += 1 - ideal_balance[demog] = [count / m_data for count in counts] - - return ideal_balance \ No newline at end of file diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index e1c1b9f..dc4c77c 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -7,9 +7,9 @@ try: import pandas as pd except ImportError: - ImportError('Package `pandas` needs to be installed in order to use the ' - 'allocate_pandas module. Please first install `pandas` via ' - 'pip or similar.') + raise ImportError('Package `pandas` needs to be installed in order to use ' + 'the allocate_pandas module. Please first install ' + '`pandas` via pip or similar.') def allocate_pandas(participants: pd.DataFrame, @@ -21,7 +21,6 @@ def allocate_pandas(participants: pd.DataFrame, settings: None | dict = None, return_full: bool = False) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame, AllocatorResult]: # Check that the dataframe index is unique. - print("enough") if not participants.index.is_unique: raise Exception('Index of dataframe must be unique.') diff --git a/groupselect/tests/test_pandas.py b/groupselect/tests/test_pandas.py index fe2024e..5390e62 100644 --- a/groupselect/tests/test_pandas.py +++ b/groupselect/tests/test_pandas.py @@ -5,29 +5,9 @@ from groupselect.examples import example_data_pd -# def test_100i_4j_3k_3d_data(): -# df = example_data_pd['100i_4j_3k_3d_data'] -# for fields in ({'age': 'diversify', 'gender': 'diversify'}, -# {'gender': 'diversify', 'age': 'diversify'}, -# {'age': 'diversify', 'gender': 'diversify', -# 'region': 'diversify', 'nation': 'diversify'}): -# for n_part_per_group in (3 * [6], 4 * [8],): -# res = df.groupselect.allocate( -# fields=fields, -# n_part_per_group=n_part_per_group, -# ) -# with pd.option_context('display.max_rows', None, 'display.max_columns', None): -# print(res) - - def test_philipps_example_data(): df = example_data_pd['philipps_example_data'] - for fields in (#{'age': 'diversify', 'gender': 'diversify'}, - #{'gender': 'diversify', 'age': 'diversify'}, - #{'age': 'diversify', 'gender': 'diversify', - # 'region': 'diversify', 'ethnicity': 'diversify', - # 'education': 'diversify'}, - {'age': 'diversify', 'gender': 'diversify', + for fields in ({'age': 'diversify', 'gender': 'diversify', 'photo consent': 'cluster'},): for n_part_per_group in (3 * [6], 4 * [8],): res = df.groupselect.allocate( From e23c095e04f17a2c24f2912060ef3834868d8545 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:10:16 +0200 Subject: [PATCH 14/26] Fixed calc_meetings. --- groupselect/allocation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/groupselect/allocation.py b/groupselect/allocation.py index 7c240f5..e7022ad 100644 --- a/groupselect/allocation.py +++ b/groupselect/allocation.py @@ -81,7 +81,7 @@ def calc_meetings(self) -> dict[int, dict[int, int]]: for allocation in self for group in allocation for p_id in group - if p_id + if p_id==p_id } meetings = {} From 12e82fb23567597a582387920361bb1387cde8a9 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:11:24 +0200 Subject: [PATCH 15/26] Fixed settings=None bug in allocate_pandas. --- groupselect/allocate_pandas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index dc4c77c..3aeebb5 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -49,8 +49,8 @@ def allocate_pandas(participants: pd.DataFrame, participants_codes.columns.tolist().index(k): v for k, v in fields.items() } - settings_numpy = settings.copy() - if "pareto_probs" in settings: + settings_numpy = (settings or {}).copy() + if settings is not None and "pareto_probs" in settings: settings_numpy["pareto_probs"] = { participants_codes.columns.tolist().index(k): v for k, v in settings_numpy["pareto_probs"].items() From 88a9eb14efe16d41924ae64717130767471766d9 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:12:39 +0200 Subject: [PATCH 16/26] Made pandas an optional dependency. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bfabe9e..8301ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,3 +12,6 @@ dependencies = [ "numpy>=2.3.4", "pytest>=9.0.0", ] + +[project.optional-dependencies] +pandas = ["pandas>=2.0"] From 27cbd13567c884423a053b77a078de294313f388 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:15:31 +0200 Subject: [PATCH 17/26] Fixed pandas handling in allocate.py. --- groupselect/allocation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/groupselect/allocation.py b/groupselect/allocation.py index e7022ad..d23004d 100644 --- a/groupselect/allocation.py +++ b/groupselect/allocation.py @@ -1,9 +1,15 @@ +from __future__ import annotations + from collections import Counter from itertools import combinations from math import comb import numpy as np -import pandas as pd + +try: + import pandas as pd +except ImportError: + pd = None class ParticipantGroup(list[int]): @@ -122,7 +128,7 @@ def _calc_diversity_score_single_field(self, values_field: np.array): return ret / len(self) def calc_diversity_score(self, participants_data: np.ndarray[int] | pd.DataFrame) -> float: - if isinstance(participants_data, pd.DataFrame): + if pd is not None and isinstance(participants_data, pd.DataFrame): participants_data = ( participants_data .astype("category") From b0f0a17c9fb40414d759e0d26d9a9821b2e047e2 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:26:17 +0200 Subject: [PATCH 18/26] Fixed demog_scores and demog_total bug. --- groupselect/algorithms/algorithm_hermes.py | 35 +++++++++------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 95eae44..686dc59 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -331,10 +331,9 @@ def pareto_swaps(shuffled_pids, candidate_profiles = generate_combinations(candidate_demogs, pid_info) candidate_swaps = {} - for k, v in cats_diverse.items(): - demog_total = k - - demog_scores = [[False] * (demog_total)] * m_data + # Per swap-candidate record of which diversity fields (keyed by + # their field ID) that specific swap would pareto-improve. + demog_scores: dict[int, set[int]] = {} for profile in candidate_profiles: @@ -345,7 +344,7 @@ def pareto_swaps(shuffled_pids, candidate_swap_tables = [ x for x in table_demog_evaluations if x != table_no] for candidate_table in candidate_swap_tables: - demog_pareto = [False] * (demog_total) + demog_pareto_fields = set() pareto_score = 0 pareto_profile = table_demog_evaluations[candidate_table][1] table_valid = True @@ -353,7 +352,7 @@ def pareto_swaps(shuffled_pids, if pid_info[demog] in pareto_profile[demog][profile[index]]: pareto_score += 1 - demog_pareto[demog-1] = True + demog_pareto_fields.add(demog) elif pid_info[demog] != profile[index]: table_valid = False @@ -367,9 +366,7 @@ def pareto_swaps(shuffled_pids, candidate_swaps[swap_pid] = pareto_score + \ candidate_profiles[profile] - for i in range(0, demog_total): - if demog_pareto[i - 1] == True: - demog_scores[swap_pid][i - 1] = True + demog_scores.setdefault(swap_pid, set()).update(demog_pareto_fields) else: for swap_pid in temp_allocations_update[candidate_table]: @@ -383,9 +380,7 @@ def pareto_swaps(shuffled_pids, candidate_swaps[swap_pid] = pareto_score + \ candidate_profiles[profile] - for i in range(0, demog_total): - if demog_pareto[i-1] == True: - demog_scores[swap_pid][i-1] = True + demog_scores.setdefault(swap_pid, set()).update(demog_pareto_fields) if len(candidate_swaps) == 0: @@ -432,7 +427,7 @@ def pareto_swaps(shuffled_pids, for key in keys_to_remove: del final_meetings[key] del final_candidates[key] - final_swap = select_key(final_candidates, final_meetings, pareto_probs, random, demog_scores, demog_total) + final_swap = select_key(final_candidates, final_meetings, pareto_probs, random, demog_scores) if final_swap == None: continue @@ -460,15 +455,13 @@ def select_key(pareto, meet, pareto_probs, random, - demog_scores, - demog_total): - relevant_demogs = [] - + demog_scores): + # Determine which diversity fields the chosen candidate swap actually + # pareto-improves, and use the highest configured probability among + # those fields (falling back to the overall maximum if the candidate + # improves no field, e.g. it was only accepted for its meeting score). k = list(pareto.keys())[0] - for i in range (0, demog_total): - if demog_scores[k][i] == True: - - relevant_demogs.append(pareto_probs[i+1]) + relevant_demogs = [pareto_probs[field_id] for field_id in demog_scores.get(k, ())] if relevant_demogs != []: pareto_prob_copy = max(relevant_demogs) else: From 1455fa5b7a2f78c1e9c268d71a10f4388dd764c1 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:35:50 +0200 Subject: [PATCH 19/26] Moved pytest to dependency group. --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8301ef1..e7fbfdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,12 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "numpy>=2.3.4", - "pytest>=9.0.0", ] [project.optional-dependencies] pandas = ["pandas>=2.0"] + +[dependency-groups] +dev = [ + "pytest>=9.0.0", +] From 5a6cddd810a7a849565dafcd2ecd4c65f4c2a259 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:48:06 +0200 Subject: [PATCH 20/26] Code deduplication between DREAM and HERMES. --- groupselect/algorithms/algorithm_dream.py | 519 +++++++++++---------- groupselect/algorithms/algorithm_hermes.py | 411 ++-------------- 2 files changed, 316 insertions(+), 614 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index 6dca641..a93783b 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -106,71 +106,73 @@ def algorithm_dream(participants: np.ndarray[int], return AllocatorResult(final_results2) -def allocate(tables, - peopledata_vals_used, - order_cluster_dict, - order_diverse_dict, - m_data, - nallocations, - cluster_tables, - pareto_prob, - n_swap_loops, - progress_bar, - previous_meetings, - no_cluster_agents, - val_cluster, - manuals, - random): - n_rounds = nallocations - for i in range(m_data): - for j in range( i +1, m_data): - pair = (i ,j) - if pair not in previous_meetings: - previous_meetings[pair] = 0 - - allocation_attempts: list[AllocationEnsemble] = [ - AllocationEnsemble() - for _ in range(1) - ] +# --- Shared allocation machinery --------------------------------------- +# +# The functions below implement DREAM's core loop: build an initial random +# seating, then run rounds of Pareto swaps that trade off demographic +# balance against unique-meeting count. HERMES (algorithm_hermes.py) reuses +# all of it unchanged, overriding only `pareto_swaps` (injected into +# `run_round` below) to weigh each diversity field's swap-acceptance +# probability individually instead of using one fixed probability/threshold +# for every field. `evaluate_actions`, `evaluate_demographics`, and +# `select_key` accept optional `threshold`/`pareto_prob` overrides for the +# same reason, defaulting to DREAM's original fixed values. +def evaluate_meetings(table, + previous_meetings): - for round_no in range(n_rounds): - if progress_bar: progress_bar(round_no +1) + total_meetings = {} + for i in range(len(table)): + for j in range(i + 1, len(table)): + agent1, agent2 = min(table[i], table[j]), max( + table[i], table[j]) + # Sum the values from pairs_dict for the pair of agents + total_meetings[agent1] = total_meetings.get( + agent1, 0) + previous_meetings.get((agent1, agent2), 0) + total_meetings[agent2] = total_meetings.get( + agent2, 0) + previous_meetings.get((agent1, agent2), 0) + return (total_meetings) - if not(isinstance(tables, int)): - no_tables = tables[round_no] - else: - no_tables = tables - no_larger_tables = m_data % no_tables +def evaluate_swap(original_id, + swap_id, + allocations, + table_meeting_evaluations, + previous_meetings): + for index, table in enumerate(allocations): + if swap_id in table: + swap_table = index + if original_id in table: + table_no = index - seats = math.ceil(m_data / no_tables) + original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( + x for x in table_meeting_evaluations[swap_table].values()) + original_table = allocations[table_no] + swap_table = allocations[swap_table] + original_table_2 = [swap_id if x == original_id else x for x in original_table] + swap_table_2 = [original_id if x == swap_id else x for x in swap_table] + meetings_1 = evaluate_meetings(original_table_2, previous_meetings) + meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) - min_cluster_tables = math.ceil(no_cluster_agents /seats) + new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) - n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) - no_smaller_tables = no_tables - no_larger_tables + return original_meetings - new_meetings - if no_larger_tables == 0: - template = [[None for s in range(seats)] for r in range(no_smaller_tables)] - else: - template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ - [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] - allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, - order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, - pareto_prob, random) +def generate_combinations(demogs, + info): + demographics = list(demogs.keys()) - allocation = Allocation( - ParticipantGroup(p_id for p_id in group) - for group in allocation - ) - for n, ensemble in enumerate(allocation_attempts): - ensemble.append(allocation) + combinations_count = {} - return allocation_attempts + for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): + combination = tuple(values) + count = len(demogs) - \ + sum(1 for v in combination if v in info.values()) + combinations_count[combination] = count + return combinations_count def calculate_ideal_balance(cats_diverse, @@ -191,99 +193,107 @@ def calculate_ideal_balance(cats_diverse, return ideal_balance -def run_round(template, - n_swap_loops, - seats, - m_data, - manual_pids, - n_cluster_tables, - cats_cluster, - cats_diverse, - people, - val_cluster, - no_tables, - previous_meetings, - pareto_prob, - random) -> Allocation: - allocations = copy.deepcopy(template) - - all_pids = list(range(m_data)) +def evaluate_actions(ideal_dist, + table_dist, + cat_labels, + table_size, + threshold: float = 0.0): + table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] + actions = {} - shuffled_pids = all_pids.copy() + for index, label in enumerate(cat_labels): + actions_for_label = [] + if table_dist[index] > ideal_dist[index]: + for a, b in zip(table_discrepancies, cat_labels): + if a < threshold: + actions_for_label.append(b) + actions[label] = actions_for_label + return actions - random.shuffle(shuffled_pids) - shuffled_pids = [x for x in shuffled_pids if x not in manual_pids] +def evaluate_demographics(temp_allocations, + table_no, + people, + cats_diverse, + m_data, + threshold_func: Callable[[int], float] = lambda demog: 0.0): - cluster_table_index = list(range(n_cluster_tables)) + table = temp_allocations[table_no] + table_data = {} + for index in table: + table_data[index] = people[index] - if len(cats_cluster) == 1: - cluster_individuals = [] - for index, person in enumerate(people): - if person[next(iter(cats_cluster))] == val_cluster: - cluster_individuals.append(index) - cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] + ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) - chosen_chair = 0 + table_balance = {} + table_actions = {} + table_distances = {} + table_length = len(table) + for demog in cats_diverse: + counts = [0] * len(cats_diverse[demog]) + for person in table_data.values(): + for i, category in enumerate(cats_diverse[demog]): + if person.get(demog) == category: + counts[i] += 1 + table_balance[demog] = [count / table_length for count in counts] - total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) + table_distances[demog] = sum([abs(x - y) for x, y in zip( + ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) - if len(cluster_individuals) > total_clustering_spaces: - raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") - for agent in cluster_individuals: - agent_assigned = 0 - while (agent_assigned == 0): - table_no = chosen_chair % len(cluster_table_index) - seat_no = math.floor( - chosen_chair / len(cluster_table_index) % seats) - if allocations[table_no][seat_no] is None: - allocations[table_no][seat_no] = agent - agent_assigned = 1 - chosen_chair += 1 - else: - cluster_individuals = [] + table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], + len(table), threshold=threshold_func(demog)) - non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] - chosen_chair = 0 + return table_distances, table_actions - for agent in non_cluster_individuals: - agent_assigned = 0 - while (agent_assigned == 0): - table_no = chosen_chair % no_tables - seat_no = math.floor(chosen_chair / no_tables % seats) - if allocations[table_no][seat_no] is None: - allocations[table_no][seat_no] = agent - agent_assigned = 1 - chosen_chair += 1 - if n_swap_loops == 1: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) - else: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) - for swap_round in range(1, n_swap_loops): - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, - pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, - m_data, pareto_prob, random) +def select_key(pareto, + meet, + pareto_prob, + random): + # `pareto_prob` is normally a fixed float (DREAM). HERMES instead + # passes a callable that resolves the probability from the specific + # candidate key chosen below, so it can weigh each diversity field + # individually. + if callable(pareto_prob): + pareto_prob = pareto_prob(list(pareto.keys())[0]) - for sublist in pareto_allocations: + pareto_copy = pareto.copy() + meet_copy = meet.copy() + total_a = sum(pareto_copy.values()) - for i in range(len(sublist)): + if random.random() < pareto_prob: + if len(pareto_copy) == 1: + return next(iter(pareto_copy.keys())) - for j in range(i + 1, len(sublist)): - pair = (min(sublist[i], sublist[j]), - max(sublist[i], sublist[j])) + cumulative_prob_a = {} + cumulative_sum = 0 + for key, value in pareto_copy.items(): + cumulative_sum += value / total_a + cumulative_prob_a[key] = cumulative_sum - previous_meetings[pair] += 1 + rand_num = random.random() + for key, prob in cumulative_prob_a.items(): + if rand_num <= prob: + return key + else: + meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} + if len(meet_copy) == 0: + return None + if len(meet_copy) == 1: + return next(iter(meet_copy.keys())) - this_alloc = Allocation( - ParticipantGroup(list) - for list in pareto_allocations - ) + total_b = sum(meet_copy.values()) + cumulative_prob_b = {} + cumulative_sum = 0 + for key, value in meet_copy.items(): + cumulative_sum += value / total_b + cumulative_prob_b[key] = cumulative_sum + rand_num = random.random() - return this_alloc + for key, prob in cumulative_prob_b.items(): + if rand_num <= prob: + return key def pareto_swaps(shuffled_pids, @@ -296,7 +306,8 @@ def pareto_swaps(shuffled_pids, previous_meetings, m_data, pareto_prob, - random): + random, + threshold_func: Callable[[int], float] = lambda demog: 0.0): temp_allocations_update = temp_allocations.copy() @@ -306,7 +317,7 @@ def pareto_swaps(shuffled_pids, table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data) + temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) for pid in shuffled_pids: for index, table in enumerate(temp_allocations_update): @@ -423,158 +434,170 @@ def pareto_swaps(shuffled_pids, temp_allocations_update[index], previous_meetings) table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data) + temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) return temp_allocations_update -def select_key(pareto, - meet, - pareto_prob, - random): - pareto_copy = pareto.copy() - meet_copy = meet.copy() - total_a = sum(pareto_copy.values()) +def run_round(template, + n_swap_loops, + seats, + m_data, + manual_pids, + n_cluster_tables, + cats_cluster, + cats_diverse, + people, + val_cluster, + no_tables, + previous_meetings, + pareto_prob, + random, + pareto_swaps=pareto_swaps) -> Allocation: + allocations = copy.deepcopy(template) - if random.random() < pareto_prob: - if len(pareto_copy) == 1: - return next(iter(pareto_copy.keys())) + all_pids = list(range(m_data)) - cumulative_prob_a = {} - cumulative_sum = 0 - for key, value in pareto_copy.items(): - cumulative_sum += value / total_a - cumulative_prob_a[key] = cumulative_sum + shuffled_pids = all_pids.copy() - rand_num = random.random() - for key, prob in cumulative_prob_a.items(): - if rand_num <= prob: - return key - else: - meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} - if len(meet_copy) == 0: - return None - if len(meet_copy) == 1: - return next(iter(meet_copy.keys())) + random.shuffle(shuffled_pids) - total_b = sum(meet_copy.values()) - cumulative_prob_b = {} - cumulative_sum = 0 - for key, value in meet_copy.items(): - cumulative_sum += value / total_b - cumulative_prob_b[key] = cumulative_sum - rand_num = random.random() + shuffled_pids = [x for x in shuffled_pids if x not in manual_pids] - for key, prob in cumulative_prob_b.items(): - if rand_num <= prob: - return key + cluster_table_index = list(range(n_cluster_tables)) -def evaluate_swap(original_id, - swap_id, - allocations, - table_meeting_evaluations, - previous_meetings): - for index, table in enumerate(allocations): - if swap_id in table: - swap_table = index - if original_id in table: - table_no = index + if len(cats_cluster) == 1: + cluster_individuals = [] + for index, person in enumerate(people): + if person[next(iter(cats_cluster))] == val_cluster: + cluster_individuals.append(index) + cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] - original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( - x for x in table_meeting_evaluations[swap_table].values()) - original_table = allocations[table_no] - swap_table = allocations[swap_table] - original_table_2 = [swap_id if x == original_id else x for x in original_table] - swap_table_2 = [original_id if x == swap_id else x for x in swap_table] - meetings_1 = evaluate_meetings(original_table_2, previous_meetings) - meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) + chosen_chair = 0 - new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) + total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) - return original_meetings - new_meetings + if len(cluster_individuals) > total_clustering_spaces: + raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") + for agent in cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % len(cluster_table_index) + seat_no = math.floor( + chosen_chair / len(cluster_table_index) % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 + else: + cluster_individuals = [] + non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] + chosen_chair = 0 -def generate_combinations(demogs, - info): - demographics = list(demogs.keys()) + for agent in non_cluster_individuals: + agent_assigned = 0 + while (agent_assigned == 0): + table_no = chosen_chair % no_tables + seat_no = math.floor(chosen_chair / no_tables % seats) + if allocations[table_no][seat_no] is None: + allocations[table_no][seat_no] = agent + agent_assigned = 1 + chosen_chair += 1 - combinations_count = {} + if n_swap_loops == 1: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + else: + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, + cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + for swap_round in range(1, n_swap_loops): + pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, + pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, + m_data, pareto_prob, random) - for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): - combination = tuple(values) - count = len(demogs) - \ - sum(1 for v in combination if v in info.values()) - combinations_count[combination] = count - return combinations_count + for sublist in pareto_allocations: + for i in range(len(sublist)): -def evaluate_meetings(table, - previous_meetings): + for j in range(i + 1, len(sublist)): + pair = (min(sublist[i], sublist[j]), + max(sublist[i], sublist[j])) - total_meetings = {} - for i in range(len(table)): - for j in range(i + 1, len(table)): - agent1, agent2 = min(table[i], table[j]), max( - table[i], table[j]) - # Sum the values from pairs_dict for the pair of agents - total_meetings[agent1] = total_meetings.get( - agent1, 0) + previous_meetings.get((agent1, agent2), 0) - total_meetings[agent2] = total_meetings.get( - agent2, 0) + previous_meetings.get((agent1, agent2), 0) + previous_meetings[pair] += 1 - return (total_meetings) + this_alloc = Allocation( + ParticipantGroup(list) + for list in pareto_allocations + ) + return this_alloc -def evaluate_demographics(temp_allocations, - table_no, - people, - cats_diverse, - m_data): +def allocate(tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_prob, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random, + run_round=run_round): + n_rounds = nallocations - table = temp_allocations[table_no] + for i in range(m_data): + for j in range( i +1, m_data): + pair = (i ,j) + if pair not in previous_meetings: + previous_meetings[pair] = 0 + allocation_attempts: list[AllocationEnsemble] = [ + AllocationEnsemble() + for _ in range(1) + ] - table_data = {} - for index in table: - table_data[index] = people[index] - ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) + for round_no in range(n_rounds): + if progress_bar: progress_bar(round_no +1) - table_balance = {} - table_actions = {} - table_distances = {} - table_length = len(table) - for demog in cats_diverse: - counts = [0] * len(cats_diverse[demog]) - for person in table_data.values(): - for i, category in enumerate(cats_diverse[demog]): - if person.get(demog) == category: - counts[i] += 1 - table_balance[demog] = [count / table_length for count in counts] - table_distances[demog] = sum([abs(x - y) for x, y in zip( - ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) + if not(isinstance(tables, int)): + no_tables = tables[round_no] + else: + no_tables = tables - table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], - len(table)) + no_larger_tables = m_data % no_tables - return table_distances, table_actions + seats = math.ceil(m_data / no_tables) + min_cluster_tables = math.ceil(no_cluster_agents /seats) -def evaluate_actions(ideal_dist, - table_dist, - cat_labels, - table_size): - table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] - actions = {} + n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) + no_smaller_tables = no_tables - no_larger_tables - for index, label in enumerate(cat_labels): - actions_for_label = [] - if table_dist[index] > ideal_dist[index]: - for a, b in zip(table_discrepancies, cat_labels): - if a < 0: - actions_for_label.append(b) - actions[label] = actions_for_label - return actions + if no_larger_tables == 0: + template = [[None for s in range(seats)] for r in range(no_smaller_tables)] + else: + template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ + [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] + + allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, + order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, + pareto_prob, random) + + allocation = Allocation( + ParticipantGroup(p_id for p_id in group) + for group in allocation + ) + for n, ensemble in enumerate(allocation_attempts): + ensemble.append(allocation) + + return allocation_attempts diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 686dc59..6120222 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -1,14 +1,22 @@ -import copy +import functools import math -from itertools import product from typing import Callable import numpy as np from groupselect.field_mode import FieldMode -from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult +from groupselect.allocation import AllocatorResult +from groupselect.algorithms.algorithm_dream import ( + allocate, + run_round, + evaluate_demographics, + select_key, + evaluate_meetings, + generate_combinations, + evaluate_swap, +) def algorithm_hermes(participants: np.ndarray[int], @@ -104,191 +112,22 @@ def algorithm_hermes(participants: np.ndarray[int], if n_swap_loops < 1: raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") - - allocation_results = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random, fields) + # HERMES reuses DREAM's allocation scaffolding (allocate -> run_round) + # unchanged, overriding only the swap-selection step (pareto_swaps + # below) so each diversity field's swap-acceptance probability is + # weighed individually instead of using one fixed probability/threshold + # for every field. + allocation_results = allocate( + tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, + cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, + val_cluster, manuals, random, + run_round=functools.partial(run_round, pareto_swaps=pareto_swaps), + ) final_results2 = max(allocation_results) return AllocatorResult(final_results2) -def allocate(tables, - peopledata_vals_used, - order_cluster_dict, - order_diverse_dict, - m_data, - nallocations, - cluster_tables, - pareto_probs, - n_swap_loops, - progress_bar, - previous_meetings, - no_cluster_agents, - val_cluster, - manuals, - random, - fields): - n_rounds = nallocations - - for i in range(m_data): - for j in range( i +1, m_data): - pair = (i ,j) - if pair not in previous_meetings: - previous_meetings[pair] = 0 - - allocation_attempts: list[AllocationEnsemble] = [ - AllocationEnsemble() - for _ in range(1) - ] - - - for round_no in range(n_rounds): - if progress_bar: progress_bar(round_no +1) - - - if not(isinstance(tables, int)): - no_tables = tables[round_no] - else: - no_tables = tables - - no_larger_tables = m_data % no_tables - - seats = math.ceil(m_data / no_tables) - - min_cluster_tables = math.ceil(no_cluster_agents /seats) - - n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) - no_smaller_tables = no_tables - no_larger_tables - - if no_larger_tables == 0: - template = [[None for s in range(seats)] for r in range(no_smaller_tables)] - else: - template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ - [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] - - allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, - order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, - pareto_probs, random, fields) - - allocation = Allocation( - ParticipantGroup(p_id for p_id in group) - for group in allocation - ) - for n, ensemble in enumerate(allocation_attempts): - ensemble.append(allocation) - - return allocation_attempts - - -def calculate_ideal_balance(cats_diverse, - m_data, - people): - ideal_balance = {} - for demog in cats_diverse: - counts = [0] * len(cats_diverse[demog]) - for row in people: - for i, category in enumerate(cats_diverse[demog]): - if row[demog] == category: - counts[i] += 1 - ideal_balance[demog] = [count / m_data for count in counts] - - - return ideal_balance - - -def run_round(template, - n_swap_loops, - seats, - m_data, - manual_pids, - n_cluster_tables, - cats_cluster, - cats_diverse, - people, - val_cluster, - no_tables, - previous_meetings, - pareto_probs, - random, - fields) -> Allocation: - allocations = copy.deepcopy(template) - - all_pids = list(range(m_data)) - - shuffled_pids = all_pids.copy() - - random.shuffle(shuffled_pids) - - shuffled_pids = [x for x in shuffled_pids if x not in manual_pids] - - cluster_table_index = list(range(n_cluster_tables)) - - if len(cats_cluster) == 1: - cluster_individuals = [] - for index, person in enumerate(people): - if person[next(iter(cats_cluster))] == val_cluster: - cluster_individuals.append(index) - cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] - - chosen_chair = 0 - - total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) - - if len(cluster_individuals) > total_clustering_spaces: - raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") - for agent in cluster_individuals: - agent_assigned = 0 - while (agent_assigned == 0): - table_no = chosen_chair % len(cluster_table_index) - seat_no = math.floor( - chosen_chair / len(cluster_table_index) % seats) - if allocations[table_no][seat_no] is None: - allocations[table_no][seat_no] = agent - agent_assigned = 1 - chosen_chair += 1 - else: - cluster_individuals = [] - - non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] - chosen_chair = 0 - - for agent in non_cluster_individuals: - agent_assigned = 0 - while (agent_assigned == 0): - table_no = chosen_chair % no_tables - seat_no = math.floor(chosen_chair / no_tables % seats) - if allocations[table_no][seat_no] is None: - allocations[table_no][seat_no] = agent - agent_assigned = 1 - chosen_chair += 1 - - if n_swap_loops == 1: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_probs, random, fields) - else: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_probs, random, fields) - for swap_round in range(1, n_swap_loops): - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, - pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, - m_data, pareto_probs, random, fields) - - for sublist in pareto_allocations: - - for i in range(len(sublist)): - - for j in range(i + 1, len(sublist)): - pair = (min(sublist[i], sublist[j]), - max(sublist[i], sublist[j])) - - previous_meetings[pair] += 1 - - this_alloc = Allocation( - ParticipantGroup(list) - for list in pareto_allocations - ) - - return this_alloc - def pareto_swaps(shuffled_pids, cluster_individuals, @@ -300,18 +139,25 @@ def pareto_swaps(shuffled_pids, previous_meetings, m_data, pareto_probs, - random, - fields): + random): + # HERMES's per-field threshold: how far a table's demographic balance + # may deviate from the panel-wide ideal before a swap is considered for + # that field, controlled by the field's own pareto probability (DREAM + # uses a fixed threshold of 0 for every field instead). Keyed directly + # by the field's own ID -- earlier code instead reverse-looked-up the + # field from its category-value list, which silently resolved to the + # wrong field whenever two diversity fields shared an identical set of + # category values. + threshold_func = lambda demog: -0.5 + pareto_probs[demog] + temp_allocations_update = temp_allocations.copy() table_meeting_evaluations = {} table_demog_evaluations = {} for index, table in enumerate(temp_allocations_update): table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) - table_demog_evaluations[index] = {} - table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, fields, pareto_probs) + temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) for pid in shuffled_pids: @@ -427,7 +273,16 @@ def pareto_swaps(shuffled_pids, for key in keys_to_remove: del final_meetings[key] del final_candidates[key] - final_swap = select_key(final_candidates, final_meetings, pareto_probs, random, demog_scores) + + # 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). + 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()) + + final_swap = select_key(final_candidates, final_meetings, pareto_prob_for, random) if final_swap == None: continue @@ -443,184 +298,8 @@ def pareto_swaps(shuffled_pids, for index in [table_no, swap_table]: table_meeting_evaluations[index] = evaluate_meetings( temp_allocations_update[index], previous_meetings) - table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, fields, pareto_probs) + temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) return temp_allocations_update - - -def select_key(pareto, - meet, - pareto_probs, - random, - demog_scores): - # Determine which diversity fields the chosen candidate swap actually - # pareto-improves, and use the highest configured probability among - # those fields (falling back to the overall maximum if the candidate - # improves no field, e.g. it was only accepted for its meeting score). - k = list(pareto.keys())[0] - relevant_demogs = [pareto_probs[field_id] for field_id in demog_scores.get(k, ())] - if relevant_demogs != []: - pareto_prob_copy = max(relevant_demogs) - else: - pareto_prob_copy = max(pareto_probs.values()) - - pareto_copy = pareto.copy() - meet_copy = meet.copy() - total_a = sum(pareto_copy.values()) - if random.random() < pareto_prob_copy: - if len(pareto_copy) == 1: - return next(iter(pareto_copy.keys())) - - cumulative_prob_a = {} - cumulative_sum = 0 - for key, value in pareto_copy.items(): - cumulative_sum += value / total_a - cumulative_prob_a[key] = cumulative_sum - - rand_num = random.random() - for key, prob in cumulative_prob_a.items(): - if rand_num <= prob: - return key - else: - meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} - if len(meet_copy) == 0: - return None - if len(meet_copy) == 1: - return next(iter(meet_copy.keys())) - - total_b = sum(meet_copy.values()) - cumulative_prob_b = {} - cumulative_sum = 0 - for key, value in meet_copy.items(): - cumulative_sum += value / total_b - cumulative_prob_b[key] = cumulative_sum - rand_num = random.random() - - for key, prob in cumulative_prob_b.items(): - if rand_num <= prob: - return key - - -def evaluate_swap(original_id, - swap_id, - allocations, - table_meeting_evaluations, - previous_meetings): - - for index, table in enumerate(allocations): - if swap_id in table: - swap_table = index - if original_id in table: - table_no = index - - original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( - x for x in table_meeting_evaluations[swap_table].values()) - original_table = allocations[table_no] - swap_table = allocations[swap_table] - original_table_2 = [swap_id if x == original_id else x for x in original_table] - swap_table_2 = [original_id if x == swap_id else x for x in swap_table] - meetings_1 = evaluate_meetings(original_table_2, previous_meetings) - meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) - - new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) - - return original_meetings - new_meetings - - -def generate_combinations(demogs, - info): - demographics = list(demogs.keys()) - - combinations_count = {} - - for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): - combination = tuple(values) - count = len(demogs) - \ - sum(1 for v in combination if v in info.values()) - combinations_count[combination] = count - return combinations_count - - -def evaluate_meetings(table, - previous_meetings): - - total_meetings = {} - for i in range(len(table)): - for j in range(i + 1, len(table)): - agent1, agent2 = min(table[i], table[j]), max( - table[i], table[j]) - # Sum the values from pairs_dict for the pair of agents - total_meetings[agent1] = total_meetings.get( - agent1, 0) + previous_meetings.get((agent1, agent2), 0) - total_meetings[agent2] = total_meetings.get( - agent2, 0) + previous_meetings.get((agent1, agent2), 0) - return (total_meetings) - - -def evaluate_demographics(temp_allocations, - table_no, - people, - cats_diverse, - m_data, - fields, - pareto_probs): - table = temp_allocations[table_no] - - - - table_data = {} - for index in table: - table_data[index] = people[index] - - - ideal_balance = calculate_ideal_balance(cats_diverse, m_data, people) - - table_balance = {} - table_actions = {} - table_distances = {} - table_length = len(table) - - for demog in cats_diverse: - counts = [0] * len(cats_diverse[demog]) - for person in table_data.values(): - for i, category in enumerate(cats_diverse[demog]): - if person.get(demog) == category: - counts[i] += 1 - table_balance[demog] = [count / table_length for count in counts] - - table_distances[demog] = sum([abs(x - y) for x, y in zip( - ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) - - table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], - len(table), fields, cats_diverse, pareto_probs) - - - return table_distances, table_actions - - -def evaluate_actions(ideal_dist, - table_dist, - cat_labels, - table_size, - fields, - cats_diverse, - pareto_probs): - table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] - - actions = {} - - lister = list(cats_diverse.keys())[list(cats_diverse.values()).index(cat_labels)] - - threshold = -0.5 + pareto_probs[lister] - for index, label in enumerate(cat_labels): - actions_for_label = [] - if table_dist[index] > ideal_dist[index]: - for a, b in zip(table_discrepancies, cat_labels): - if a < threshold: - actions_for_label.append(b) - actions[label] = actions_for_label - - return actions From aef584dce66e467777de2d0459a57ea46a793e75 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Sun, 30 Aug 2026 23:59:10 +0200 Subject: [PATCH 21/26] Expose default parameters in DREAM and HERMES. --- groupselect/algorithms/algorithm_dream.py | 13 ++++++------- groupselect/algorithms/algorithm_hermes.py | 6 ++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index a93783b..beb47ff 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -17,7 +17,12 @@ def algorithm_dream(participants: np.ndarray[int], manuals: dict[int, int], progress_func: None | Callable = None, n_attempts: int = 3, - seed: None | int = None): + seed: None | int = None, + pareto_prob: float = 0.5, + swap_rounds: int = 1, + cluster_tables: int = 2): + if not (isinstance(pareto_prob, float) and 0.0 <= pareto_prob <= 0.5): + raise Exception(f"Pareto probability must be float in range [0.0, 0.5] but found: {pareto_prob}") nallocations = len(groups) progress_bar = progress_func @@ -54,14 +59,8 @@ def algorithm_dream(participants: np.ndarray[int], order_diverse_dict = dict(zip(order_diverse, lister)) - swap_rounds = 1 - - cluster_tables = 2 - m_data = participants.shape[0] - pareto_prob = 0.5 - seats = math.ceil(m_data /tables) previous_meetings = {} diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index 6120222..b225cd9 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -27,6 +27,8 @@ def algorithm_hermes(participants: np.ndarray[int], n_attempts: int = 3, seed: None | int = None, pareto_probs: dict[int, float] = None, + swap_rounds: int = 1, + cluster_tables: int = 2, ): pareto_probs = pareto_probs or {} for field_id in fields: @@ -71,10 +73,6 @@ def algorithm_hermes(participants: np.ndarray[int], - swap_rounds = 1 - - cluster_tables = 2 - m_data = participants.shape[0] seats = math.ceil(m_data /tables) From c3dfd5d64ab7395c2e7698ec13a892cc770655ca Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 00:05:50 +0200 Subject: [PATCH 22/26] Fixed val_cluster bug in DREAM. --- groupselect/algorithms/algorithm_dream.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index beb47ff..d0cc2e4 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -35,14 +35,14 @@ def algorithm_dream(participants: np.ndarray[int], order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] + X = participants[:, order_cluster] + X = np.unique(X) + if len(order_cluster ) >=1: - val_cluster = 'cluster' + val_cluster = X[len(X)-1] else: val_cluster = '' - X = participants[:, order_cluster] - X = np.unique(X) - Y = participants[:, order_diverse] lister = [None] * len(order_diverse) From 3571151a6045b3e80fe0789de92a11077e2a0a45 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 00:06:48 +0200 Subject: [PATCH 23/26] Removed obsolete n_attempts from DREAM and HERMES --- groupselect/algorithms/algorithm_dream.py | 1 - groupselect/algorithms/algorithm_hermes.py | 1 - 2 files changed, 2 deletions(-) diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index d0cc2e4..dc0ab62 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -16,7 +16,6 @@ def algorithm_dream(participants: np.ndarray[int], groups: list[(int, int)], manuals: dict[int, int], progress_func: None | Callable = None, - n_attempts: int = 3, seed: None | int = None, pareto_prob: float = 0.5, swap_rounds: int = 1, diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index b225cd9..dfa4506 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -24,7 +24,6 @@ def algorithm_hermes(participants: np.ndarray[int], groups: list[(int, int)], manuals: dict[int, int], progress_func: None | Callable = None, - n_attempts: int = 3, seed: None | int = None, pareto_probs: dict[int, float] = None, swap_rounds: int = 1, From 49f8eb6390fd2f165c378e77bb76721a65d83361 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 00:25:10 +0200 Subject: [PATCH 24/26] Checked and fixed formatting and lintering with ruff --- groupselect/__init__.py | 8 +- groupselect/algorithms/__init__.py | 12 +- groupselect/algorithms/algorithm_dream.py | 620 ++++++++++++++------- groupselect/algorithms/algorithm_hermes.py | 326 +++++++---- groupselect/algorithms/algorithm_legacy.py | 175 +++--- groupselect/allocate_numpy.py | 134 +++-- groupselect/allocate_pandas.py | 113 ++-- groupselect/allocation.py | 38 +- groupselect/examples/__init__.py | 11 +- groupselect/field_mode.py | 4 + groupselect/tests/test_load_examples.py | 3 + groupselect/tests/test_pandas.py | 23 +- pyproject.toml | 11 + 13 files changed, 982 insertions(+), 496 deletions(-) diff --git a/groupselect/__init__.py b/groupselect/__init__.py index f76d1ab..038cd93 100644 --- a/groupselect/__init__.py +++ b/groupselect/__init__.py @@ -1,7 +1,13 @@ +"""GroupSelect: allocate participants into diverse groups.""" + from groupselect.algorithms import Algorithm from groupselect.field_mode import FieldMode from groupselect.allocate_numpy import allocate_numpy -from groupselect.allocation import Allocation, AllocationEnsemble, AllocatorResult +from groupselect.allocation import ( + Allocation, + AllocationEnsemble, + AllocatorResult, +) try: diff --git a/groupselect/algorithms/__init__.py b/groupselect/algorithms/__init__.py index c223247..f02e0ad 100644 --- a/groupselect/algorithms/__init__.py +++ b/groupselect/algorithms/__init__.py @@ -1,20 +1,28 @@ +"""Available allocation algorithms and lookup of their implementations.""" + from enum import Enum, unique from importlib import import_module from groupselect.algorithms.algorithm_legacy import algorithm_legacy + # Define Enum data type for different algorithms @unique class Algorithm(Enum): + """Identifier for a supported allocation algorithm.""" + Legacy = 0 DREAM = 1 HERMES = 2 + # Define functions of algorithms. algorithm_funcs = { algorithm: getattr( - import_module('groupselect.algorithms.algorithm_' + algorithm.name.lower()), - 'algorithm_' + algorithm.name.lower() + import_module( + "groupselect.algorithms.algorithm_" + algorithm.name.lower() + ), + "algorithm_" + algorithm.name.lower(), ) for algorithm in Algorithm } diff --git a/groupselect/algorithms/algorithm_dream.py b/groupselect/algorithms/algorithm_dream.py index dc0ab62..3f15588 100644 --- a/groupselect/algorithms/algorithm_dream.py +++ b/groupselect/algorithms/algorithm_dream.py @@ -1,3 +1,4 @@ +"""DREAM allocation algorithm.""" import copy import math @@ -8,20 +9,31 @@ from groupselect.field_mode import FieldMode -from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult - - -def algorithm_dream(participants: np.ndarray[int], - fields: dict[int, FieldMode], - groups: list[(int, int)], - manuals: dict[int, int], - progress_func: None | Callable = None, - seed: None | int = None, - pareto_prob: float = 0.5, - swap_rounds: int = 1, - cluster_tables: int = 2): +from groupselect.allocation import ( + Allocation, + ParticipantGroup, + AllocationEnsemble, + AllocatorResult, +) + + +def algorithm_dream( + participants: np.ndarray[int], + fields: dict[int, FieldMode], + groups: list[(int, int)], + manuals: dict[int, int], + progress_func: None | Callable = None, + seed: None | int = None, + pareto_prob: float = 0.5, + swap_rounds: int = 1, + cluster_tables: int = 2, +): + """Allocate participants into groups using the DREAM algorithm.""" if not (isinstance(pareto_prob, float) and 0.0 <= pareto_prob <= 0.5): - raise Exception(f"Pareto probability must be float in range [0.0, 0.5] but found: {pareto_prob}") + raise Exception( + f"Pareto probability must be float in range [0.0, 0.5] but " + f"found: {pareto_prob}" + ) nallocations = len(groups) progress_bar = progress_func @@ -33,43 +45,46 @@ def algorithm_dream(participants: np.ndarray[int], order_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] order_diverse = [k for k, v in fields.items() if v == FieldMode.Diversify] - X = participants[:, order_cluster] X = np.unique(X) - if len(order_cluster ) >=1: - val_cluster = X[len(X)-1] + if len(order_cluster) >= 1: + val_cluster = X[len(X) - 1] else: - val_cluster = '' + val_cluster = "" Y = participants[:, order_diverse] lister = [None] * len(order_diverse) for i in range(0, len(order_diverse)): + lister[i] = [int(item[i]) for item in Y] - lister[i] =[int(item[i]) for item in Y] - - lister[i] = np.unique(lister[i]) - lister[i] = lister[i].tolist() - + lister[i] = np.unique(lister[i]) + lister[i] = lister[i].tolist() order_cluster_dict = dict(zip(order_cluster, [list(X)])) order_diverse_dict = dict(zip(order_diverse, lister)) - m_data = participants.shape[0] - seats = math.ceil(m_data /tables) + seats = math.ceil(m_data / tables) previous_meetings = {} try: random = np.random.default_rng(seed) except: - raise Exception("Error: Random seed incorrect!", "There was a problem setting the random seed. Please check your input!") + raise Exception( + "Error: Random seed incorrect!", + "There was a problem setting the random seed. Please check " + "your input!", + ) - if (nallocations < 1): - raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") + if nallocations < 1: + raise Exception( + "Error: Wrong allocation number!", + "The number of computed allocations must at least be 1!", + ) peopledata_vals_used = [{} for i in range(m_data)] @@ -78,24 +93,52 @@ def algorithm_dream(participants: np.ndarray[int], 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.") + raise Exception( + "Error: One diversification field required!", + "You have to set at least one field that is used to " + "diversify people across groups.", + ) - if len(order_cluster_dict ) >1: - raise Exception("Error: Only one cluster field permitted. Please reduce the number of cluster fields.") + if len(order_cluster_dict) > 1: + raise Exception( + "Error: Only one cluster field permitted. Please reduce " + "the number of cluster fields." + ) no_cluster_agents = 0 - - if len(order_cluster_dict ) ==1: + if len(order_cluster_dict) == 1: cluster_key = next(iter(order_cluster_dict)) - no_cluster_agents = sum(1 for person in peopledata_vals_used if person[cluster_key] == val_cluster) - + no_cluster_agents = sum( + 1 + for person in peopledata_vals_used + if person[cluster_key] == val_cluster + ) n_swap_loops = int(swap_rounds) if n_swap_loops < 1: - raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") + raise Exception( + "Error: at least one round of meeting optimization must be " + "specified (in *advanced settings*)" + ) - n_results = allocate(tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, cluster_tables, pareto_prob, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, val_cluster, manuals, random) + n_results = allocate( + tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_prob, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random, + ) allocation_results = n_results @@ -117,65 +160,77 @@ def algorithm_dream(participants: np.ndarray[int], # `select_key` accept optional `threshold`/`pareto_prob` overrides for the # same reason, defaulting to DREAM's original fixed values. -def evaluate_meetings(table, - previous_meetings): +def evaluate_meetings(table, previous_meetings): + """Sum each agent's meeting count with the other agents at its table.""" total_meetings = {} for i in range(len(table)): for j in range(i + 1, len(table)): - agent1, agent2 = min(table[i], table[j]), max( - table[i], table[j]) + agent1, agent2 = min(table[i], table[j]), max(table[i], table[j]) # Sum the values from pairs_dict for the pair of agents total_meetings[agent1] = total_meetings.get( - agent1, 0) + previous_meetings.get((agent1, agent2), 0) + agent1, 0 + ) + previous_meetings.get((agent1, agent2), 0) total_meetings[agent2] = total_meetings.get( - agent2, 0) + previous_meetings.get((agent1, agent2), 0) + agent2, 0 + ) + previous_meetings.get((agent1, agent2), 0) - return (total_meetings) + return total_meetings -def evaluate_swap(original_id, - swap_id, - allocations, - table_meeting_evaluations, - previous_meetings): +def evaluate_swap( + original_id, + swap_id, + allocations, + table_meeting_evaluations, + previous_meetings, +): + """Return the meeting-count change from swapping two agents' tables.""" for index, table in enumerate(allocations): if swap_id in table: swap_table = index if original_id in table: table_no = index - original_meetings = sum(x for x in table_meeting_evaluations[table_no].values()) + sum( - x for x in table_meeting_evaluations[swap_table].values()) + original_meetings = sum( + x for x in table_meeting_evaluations[table_no].values() + ) + sum(x for x in table_meeting_evaluations[swap_table].values()) original_table = allocations[table_no] swap_table = allocations[swap_table] - original_table_2 = [swap_id if x == original_id else x for x in original_table] + original_table_2 = [ + swap_id if x == original_id else x for x in original_table + ] swap_table_2 = [original_id if x == swap_id else x for x in swap_table] meetings_1 = evaluate_meetings(original_table_2, previous_meetings) meetings_2 = evaluate_meetings(swap_table_2, previous_meetings) - new_meetings = sum(x for x in meetings_1.values()) + sum(x for x in meetings_2.values()) + new_meetings = sum(x for x in meetings_1.values()) + sum( + x for x in meetings_2.values() + ) return original_meetings - new_meetings -def generate_combinations(demogs, - info): +def generate_combinations(demogs, info): + """Count, per category combination, how many values differ from info.""" demographics = list(demogs.keys()) combinations_count = {} - for values in product(*[demogs[demographic] + [info[demographic]] for demographic in demographics]): + for values in product( + *[ + demogs[demographic] + [info[demographic]] + for demographic in demographics + ] + ): combination = tuple(values) - count = len(demogs) - \ - sum(1 for v in combination if v in info.values()) + count = len(demogs) - sum(1 for v in combination if v in info.values()) combinations_count[combination] = count return combinations_count -def calculate_ideal_balance(cats_diverse, - m_data, - people): +def calculate_ideal_balance(cats_diverse, m_data, people): + """Return each category's target share of the whole participant pool.""" ideal_balance = {} i = 0 @@ -191,11 +246,10 @@ def calculate_ideal_balance(cats_diverse, return ideal_balance -def evaluate_actions(ideal_dist, - table_dist, - cat_labels, - table_size, - threshold: float = 0.0): +def evaluate_actions( + ideal_dist, table_dist, cat_labels, table_size, threshold: float = 0.0 +): + """For each over-represented label, list which labels to swap in.""" table_discrepancies = [y - x for y, x in zip(table_dist, ideal_dist)] actions = {} @@ -209,13 +263,15 @@ def evaluate_actions(ideal_dist, return actions -def evaluate_demographics(temp_allocations, - table_no, - people, - cats_diverse, - m_data, - threshold_func: Callable[[int], float] = lambda demog: 0.0): - +def evaluate_demographics( + temp_allocations, + table_no, + people, + cats_diverse, + m_data, + threshold_func: Callable[[int], float] = lambda demog: 0.0, +): + """Return a table's demographic imbalance and possible swap actions.""" table = temp_allocations[table_no] table_data = {} @@ -236,19 +292,26 @@ def evaluate_demographics(temp_allocations, counts[i] += 1 table_balance[demog] = [count / table_length for count in counts] - table_distances[demog] = sum([abs(x - y) for x, y in zip( - ideal_balance[demog], table_balance[demog])]) / len(ideal_balance[demog]) - - table_actions[demog] = evaluate_actions(ideal_balance[demog], table_balance[demog], cats_diverse[demog], - len(table), threshold=threshold_func(demog)) + table_distances[demog] = sum( + [ + abs(x - y) + for x, y in zip(ideal_balance[demog], table_balance[demog]) + ] + ) / len(ideal_balance[demog]) + + table_actions[demog] = evaluate_actions( + ideal_balance[demog], + table_balance[demog], + cats_diverse[demog], + len(table), + threshold=threshold_func(demog), + ) return table_distances, table_actions -def select_key(pareto, - meet, - pareto_prob, - random): +def select_key(pareto, meet, pareto_prob, random): + """Pick a swap candidate, favouring balance or unique meetings.""" # `pareto_prob` is normally a fixed float (DREAM). HERMES instead # passes a callable that resolves the probability from the specific # candidate key chosen below, so it can weigh each diversity field @@ -275,7 +338,11 @@ def select_key(pareto, if rand_num <= prob: return key else: - meet_copy = {key: value for key, value in meet_copy.items() if meet_copy[key] >= 0} + meet_copy = { + key: value + for key, value in meet_copy.items() + if meet_copy[key] >= 0 + } if len(meet_copy) == 0: return None if len(meet_copy) == 1: @@ -294,103 +361,166 @@ def select_key(pareto, return key -def pareto_swaps(shuffled_pids, - cluster_individuals, - cluster_table_index, - temp_allocations, - people, - cats_diverse, - manual_pids, - previous_meetings, - m_data, - pareto_prob, - random, - threshold_func: Callable[[int], float] = lambda demog: 0.0): +def pareto_swaps( + shuffled_pids, + cluster_individuals, + cluster_table_index, + temp_allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random, + threshold_func: Callable[[int], float] = lambda demog: 0.0, +): + """Select the best swap for a round using a single fixed threshold.""" temp_allocations_update = temp_allocations.copy() - table_meeting_evaluations = {} table_demog_evaluations = {} for index, table in enumerate(temp_allocations_update): - table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) + table_meeting_evaluations[index] = evaluate_meetings( + table, previous_meetings + ) table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) + temp_allocations_update, + index, + people, + cats_diverse, + m_data, + threshold_func=threshold_func, + ) for pid in shuffled_pids: for index, table in enumerate(temp_allocations_update): if pid in table: table_no = index - pid_info = {key: people[pid][key] - for key in people[pid] if key in cats_diverse} + pid_info = { + key: people[pid][key] for key in people[pid] if key in cats_diverse + } candidate_demogs = {} for demog in cats_diverse: - candidate_demogs[demog] = table_demog_evaluations[table_no][1][demog][pid_info[demog]] + candidate_demogs[demog] = table_demog_evaluations[table_no][1][ + demog + ][pid_info[demog]] candidate_profiles = generate_combinations(candidate_demogs, pid_info) candidate_swaps = {} for profile in candidate_profiles: if pid in cluster_individuals: - candidate_swap_tables = [x for x in table_demog_evaluations if ( - x != table_no) and (x in cluster_table_index)] + candidate_swap_tables = [ + x + for x in table_demog_evaluations + if (x != table_no) and (x in cluster_table_index) + ] else: candidate_swap_tables = [ - x for x in table_demog_evaluations if x != table_no] + x for x in table_demog_evaluations if x != table_no + ] for candidate_table in candidate_swap_tables: pareto_score = 0 pareto_profile = table_demog_evaluations[candidate_table][1] table_valid = True for index, demog in enumerate(pareto_profile): - - if pid_info[demog] in pareto_profile[demog][profile[index]]: + if ( + pid_info[demog] + in pareto_profile[demog][profile[index]] + ): pareto_score += 1 elif pid_info[demog] != profile[index]: table_valid = False break if table_valid: if pid in cluster_individuals: - for swap_pid in temp_allocations_update[candidate_table]: + for swap_pid in temp_allocations_update[ + candidate_table + ]: if swap_pid not in manual_pids: - if tuple(people[swap_pid][key] for key in people[swap_pid] if - key in cats_diverse) == profile: - candidate_swaps[swap_pid] = pareto_score + \ - candidate_profiles[profile] + if ( + tuple( + people[swap_pid][key] + for key in people[swap_pid] + if key in cats_diverse + ) + == profile + ): + candidate_swaps[swap_pid] = ( + pareto_score + + candidate_profiles[profile] + ) else: - for swap_pid in temp_allocations_update[candidate_table]: + for swap_pid in temp_allocations_update[ + candidate_table + ]: if swap_pid not in cluster_individuals: if swap_pid not in manual_pids: - if tuple(people[swap_pid][key] for key in people[swap_pid] if - key in cats_diverse) == profile: - candidate_swaps[swap_pid] = pareto_score + \ - candidate_profiles[profile] + if ( + tuple( + people[swap_pid][key] + for key in people[swap_pid] + if key in cats_diverse + ) + == profile + ): + candidate_swaps[swap_pid] = ( + pareto_score + + candidate_profiles[profile] + ) if len(candidate_swaps) == 0: continue candidate_meetings = {} for swap in candidate_swaps: - candidate_meetings[swap] = evaluate_swap(pid, swap, temp_allocations_update, table_meeting_evaluations, - previous_meetings) - - candidate_swaps = {key: value for key, value in candidate_swaps.items() if ( - candidate_swaps[key] > 0) or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0)} + candidate_meetings[swap] = evaluate_swap( + pid, + swap, + temp_allocations_update, + table_meeting_evaluations, + previous_meetings, + ) + + candidate_swaps = { + key: value + for key, value in candidate_swaps.items() + if (candidate_swaps[key] > 0) + or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0) + } if len(candidate_swaps) == 0: continue distinct_candidates = {} for distinct_value in {value for value in candidate_swaps.values()}: distinct_keys = { - key for key, value in candidate_swaps.items() if value == distinct_value} + key + for key, value in candidate_swaps.items() + if value == distinct_value + } max_meetings = max( - value for key, value in candidate_meetings.items() if key in distinct_keys) - distinct_candidates.update({key: value for key, value in candidate_swaps.items( - ) if (value == distinct_value) and (candidate_meetings[key] == max_meetings)}) - distinct_meetings = {key: value for key, value in candidate_meetings.items( - ) if key in distinct_candidates} + value + for key, value in candidate_meetings.items() + if key in distinct_keys + ) + distinct_candidates.update( + { + key: value + for key, value in candidate_swaps.items() + if (value == distinct_value) + and (candidate_meetings[key] == max_meetings) + } + ) + distinct_meetings = { + key: value + for key, value in candidate_meetings.items() + if key in distinct_candidates + } reverse_mapping = {} for key, value in distinct_candidates.items(): @@ -402,19 +532,28 @@ def pareto_swaps(shuffled_pids, for value, keys in reverse_mapping.items(): final_candidates[random.choice(keys)] = value final_meetings = { - key: value for key, value in distinct_meetings.items() if key in final_candidates} + key: value + for key, value in distinct_meetings.items() + if key in final_candidates + } keys_to_remove = set() for key in final_meetings.keys(): - if any(final_meetings[other_key] >= final_meetings[key] and final_candidates[other_key] > final_candidates[ - key] for other_key in final_meetings.keys() if other_key != key): + if any( + final_meetings[other_key] >= final_meetings[key] + and final_candidates[other_key] > final_candidates[key] + for other_key in final_meetings.keys() + if other_key != key + ): keys_to_remove.add(key) for key in keys_to_remove: del final_meetings[key] del final_candidates[key] - final_swap = select_key(final_candidates, final_meetings, pareto_prob, random) + final_swap = select_key( + final_candidates, final_meetings, pareto_prob, random + ) if final_swap == None: continue @@ -423,35 +562,49 @@ def pareto_swaps(shuffled_pids, swap_table = index temp_allocations_update[table_no] = [ - final_swap if x == pid else x for x in temp_allocations_update[table_no]] + final_swap if x == pid else x + for x in temp_allocations_update[table_no] + ] temp_allocations_update[swap_table] = [ - pid if x == final_swap else x for x in temp_allocations_update[swap_table]] + pid if x == final_swap else x + for x in temp_allocations_update[swap_table] + ] for index in [table_no, swap_table]: table_meeting_evaluations[index] = evaluate_meetings( - temp_allocations_update[index], previous_meetings) + temp_allocations_update[index], previous_meetings + ) table_demog_evaluations[index] = {} table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) + temp_allocations_update, + index, + people, + cats_diverse, + m_data, + threshold_func=threshold_func, + ) return temp_allocations_update -def run_round(template, - n_swap_loops, - seats, - m_data, - manual_pids, - n_cluster_tables, - cats_cluster, - cats_diverse, - people, - val_cluster, - no_tables, - previous_meetings, - pareto_prob, - random, - pareto_swaps=pareto_swaps) -> Allocation: +def run_round( + template, + n_swap_loops, + seats, + m_data, + manual_pids, + n_cluster_tables, + cats_cluster, + cats_diverse, + people, + val_cluster, + no_tables, + previous_meetings, + pareto_prob, + random, + pareto_swaps=pareto_swaps, +) -> Allocation: + """Build one allocation from a template, then run rounds of swaps.""" allocations = copy.deepcopy(template) all_pids = list(range(m_data)) @@ -464,26 +617,33 @@ def run_round(template, cluster_table_index = list(range(n_cluster_tables)) - if len(cats_cluster) == 1: cluster_individuals = [] for index, person in enumerate(people): if person[next(iter(cats_cluster))] == val_cluster: cluster_individuals.append(index) - cluster_individuals = [x for x in cluster_individuals if x not in manual_pids] + cluster_individuals = [ + x for x in cluster_individuals if x not in manual_pids + ] chosen_chair = 0 - total_clustering_spaces = sum(allocations[index].count(None) for index in cluster_table_index) + total_clustering_spaces = sum( + allocations[index].count(None) for index in cluster_table_index + ) if len(cluster_individuals) > total_clustering_spaces: - raise ValueError("Too many manual allocations to clustering tables: please reduce manual allocations.") + raise ValueError( + "Too many manual allocations to clustering tables: " + "please reduce manual allocations." + ) for agent in cluster_individuals: agent_assigned = 0 - while (agent_assigned == 0): + while agent_assigned == 0: table_no = chosen_chair % len(cluster_table_index) seat_no = math.floor( - chosen_chair / len(cluster_table_index) % seats) + chosen_chair / len(cluster_table_index) % seats + ) if allocations[table_no][seat_no] is None: allocations[table_no][seat_no] = agent agent_assigned = 1 @@ -491,12 +651,14 @@ def run_round(template, else: cluster_individuals = [] - non_cluster_individuals = [x for x in shuffled_pids if x not in cluster_individuals] + non_cluster_individuals = [ + x for x in shuffled_pids if x not in cluster_individuals + ] chosen_chair = 0 for agent in non_cluster_individuals: agent_assigned = 0 - while (agent_assigned == 0): + while agent_assigned == 0: table_no = chosen_chair % no_tables seat_no = math.floor(chosen_chair / no_tables % seats) if allocations[table_no][seat_no] is None: @@ -505,69 +667,101 @@ def run_round(template, chosen_chair += 1 if n_swap_loops == 1: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + pareto_allocations = pareto_swaps( + shuffled_pids, + cluster_individuals, + cluster_table_index, + allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random, + ) else: - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, allocations, people, - cats_diverse, manual_pids, previous_meetings, m_data, pareto_prob, random) + pareto_allocations = pareto_swaps( + shuffled_pids, + cluster_individuals, + cluster_table_index, + allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random, + ) for swap_round in range(1, n_swap_loops): - pareto_allocations = pareto_swaps(shuffled_pids, cluster_individuals, cluster_table_index, - pareto_allocations, people, cats_diverse, manual_pids, previous_meetings, - m_data, pareto_prob, random) + pareto_allocations = pareto_swaps( + shuffled_pids, + cluster_individuals, + cluster_table_index, + pareto_allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_prob, + random, + ) for sublist in pareto_allocations: - for i in range(len(sublist)): - for j in range(i + 1, len(sublist)): - pair = (min(sublist[i], sublist[j]), - max(sublist[i], sublist[j])) + pair = ( + min(sublist[i], sublist[j]), + max(sublist[i], sublist[j]), + ) previous_meetings[pair] += 1 this_alloc = Allocation( - ParticipantGroup(list) - for list in pareto_allocations + ParticipantGroup(list) for list in pareto_allocations ) return this_alloc -def allocate(tables, - peopledata_vals_used, - order_cluster_dict, - order_diverse_dict, - m_data, - nallocations, - cluster_tables, - pareto_prob, - n_swap_loops, - progress_bar, - previous_meetings, - no_cluster_agents, - val_cluster, - manuals, - random, - run_round=run_round): +def allocate( + tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_prob, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random, + run_round=run_round, +): + """Build the requested number of allocation rounds and return them.""" n_rounds = nallocations for i in range(m_data): - for j in range( i +1, m_data): - pair = (i ,j) + for j in range(i + 1, m_data): + pair = (i, j) if pair not in previous_meetings: previous_meetings[pair] = 0 allocation_attempts: list[AllocationEnsemble] = [ - AllocationEnsemble() - for _ in range(1) + AllocationEnsemble() for _ in range(1) ] - for round_no in range(n_rounds): - if progress_bar: progress_bar(round_no +1) - + if progress_bar: + progress_bar(round_no + 1) - if not(isinstance(tables, int)): + if not (isinstance(tables, int)): no_tables = tables[round_no] else: no_tables = tables @@ -576,24 +770,42 @@ def allocate(tables, seats = math.ceil(m_data / no_tables) - min_cluster_tables = math.ceil(no_cluster_agents /seats) + min_cluster_tables = math.ceil(no_cluster_agents / seats) n_cluster_tables = min(min_cluster_tables + cluster_tables, tables) no_smaller_tables = no_tables - no_larger_tables if no_larger_tables == 0: - template = [[None for s in range(seats)] for r in range(no_smaller_tables)] + template = [ + [None for s in range(seats)] for r in range(no_smaller_tables) + ] else: - template = [[None for s in range(seats)] for r in range(no_larger_tables)] + \ - [[None for s in range(seats - 1)] for r in range(no_smaller_tables)] - - allocation = run_round(template, n_swap_loops, seats, m_data, manuals, n_cluster_tables, order_cluster_dict, - order_diverse_dict, peopledata_vals_used, val_cluster, no_tables, previous_meetings, - pareto_prob, random) + template = [ + [None for s in range(seats)] for r in range(no_larger_tables) + ] + [ + [None for s in range(seats - 1)] + for r in range(no_smaller_tables) + ] + + allocation = run_round( + template, + n_swap_loops, + seats, + m_data, + manuals, + n_cluster_tables, + order_cluster_dict, + order_diverse_dict, + peopledata_vals_used, + val_cluster, + no_tables, + previous_meetings, + pareto_prob, + random, + ) allocation = Allocation( - ParticipantGroup(p_id for p_id in group) - for group in allocation + ParticipantGroup(p_id for p_id in group) for group in allocation ) for n, ensemble in enumerate(allocation_attempts): ensemble.append(allocation) diff --git a/groupselect/algorithms/algorithm_hermes.py b/groupselect/algorithms/algorithm_hermes.py index dfa4506..d37d612 100644 --- a/groupselect/algorithms/algorithm_hermes.py +++ b/groupselect/algorithms/algorithm_hermes.py @@ -1,3 +1,4 @@ +"""HERMES allocation algorithm, built on top of DREAM's swap machinery.""" import functools import math @@ -19,24 +20,35 @@ ) -def algorithm_hermes(participants: np.ndarray[int], - fields: dict[int, FieldMode], - groups: list[(int, int)], - manuals: dict[int, int], - progress_func: None | Callable = None, - seed: None | int = None, - pareto_probs: dict[int, float] = None, - swap_rounds: int = 1, - cluster_tables: int = 2, +def algorithm_hermes( + participants: np.ndarray[int], + fields: dict[int, FieldMode], + groups: list[(int, int)], + manuals: dict[int, int], + progress_func: None | Callable = None, + seed: None | int = None, + pareto_probs: dict[int, float] = None, + swap_rounds: int = 1, + cluster_tables: int = 2, ): + """Allocate participants into groups using the HERMES algorithm.""" pareto_probs = pareto_probs or {} for field_id in fields: - if fields[field_id]==FieldMode.Diversify: + if fields[field_id] == FieldMode.Diversify: if field_id not in pareto_probs: - raise Exception(f"Algorithm HERMES requires pareto probability to be passed for diversity fields, but " - f"none found for field {field_id}.") - if not (isinstance(pareto_probs[field_id], float) and 0.0 <= pareto_probs[field_id] <= 0.5): - raise Exception(f"Pareto probability must be float in range [0.0, 0.5] but found: {pareto_probs[field_id]}") + raise Exception( + f"Algorithm HERMES requires pareto probability to be " + f"passed for diversity fields, but none found for " + f"field {field_id}." + ) + if not ( + isinstance(pareto_probs[field_id], float) + and 0.0 <= pareto_probs[field_id] <= 0.5 + ): + raise Exception( + f"Pareto probability must be float in range " + f"[0.0, 0.5] but found: {pareto_probs[field_id]}" + ) nallocations = len(groups) progress_bar = progress_func @@ -56,33 +68,37 @@ def algorithm_hermes(participants: np.ndarray[int], lister = [None] * len(order_diverse) for i in range(0, len(order_diverse)): - lister[i] = [int(item[i]) for item in Y] lister[i] = np.unique(lister[i]) lister[i] = lister[i].tolist() - if len(order_cluster ) >=1: - val_cluster = X[len(X)-1] + if len(order_cluster) >= 1: + val_cluster = X[len(X) - 1] else: - val_cluster = '' + val_cluster = "" order_cluster_dict = dict(zip(order_cluster, [list(X)])) order_diverse_dict = dict(zip(order_diverse, lister)) - - m_data = participants.shape[0] - seats = math.ceil(m_data /tables) + seats = math.ceil(m_data / tables) previous_meetings = {} try: random = np.random.default_rng(seed) except: - raise Exception("Error: Random seed incorrect!", "There was a problem setting the random seed. Please check your input!") - - if (nallocations < 1): - raise Exception("Error: Wrong allocation number!", "The number of computed allocations must at least be 1!") + raise Exception( + "Error: Random seed incorrect!", + "There was a problem setting the random seed. Please check " + "your input!", + ) + + if nallocations < 1: + raise Exception( + "Error: Wrong allocation number!", + "The number of computed allocations must at least be 1!", + ) peopledata_vals_used = [{} for i in range(m_data)] @@ -91,23 +107,34 @@ def algorithm_hermes(participants: np.ndarray[int], 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.") - - if len(order_cluster_dict ) >1: - raise Exception("Error: Only one cluster field permitted. Please reduce the number of cluster fields.") + raise Exception( + "Error: One diversification field required!", + "You have to set at least one field that is used to " + "diversify people across groups.", + ) + + if len(order_cluster_dict) > 1: + raise Exception( + "Error: Only one cluster field permitted. Please reduce " + "the number of cluster fields." + ) no_cluster_agents = 0 - if len(order_cluster_dict ) ==1: - + if len(order_cluster_dict) == 1: cluster_key = next(iter(order_cluster_dict)) - no_cluster_agents = sum(1 for person in peopledata_vals_used if person[cluster_key] == val_cluster) - - + no_cluster_agents = sum( + 1 + for person in peopledata_vals_used + if person[cluster_key] == val_cluster + ) n_swap_loops = int(swap_rounds) if n_swap_loops < 1: - raise Exception("Error: at least one round of meeting optimization must be specified (in *advanced settings*)") + raise Exception( + "Error: at least one round of meeting optimization must be " + "specified (in *advanced settings*)" + ) # HERMES reuses DREAM's allocation scaffolding (allocate -> run_round) # unchanged, overriding only the swap-selection step (pareto_swaps @@ -115,9 +142,21 @@ def algorithm_hermes(participants: np.ndarray[int], # weighed individually instead of using one fixed probability/threshold # for every field. allocation_results = allocate( - tables, peopledata_vals_used, order_cluster_dict, order_diverse_dict, m_data, nallocations, - cluster_tables, pareto_probs, n_swap_loops, progress_bar, previous_meetings, no_cluster_agents, - val_cluster, manuals, random, + tables, + peopledata_vals_used, + order_cluster_dict, + order_diverse_dict, + m_data, + nallocations, + cluster_tables, + pareto_probs, + n_swap_loops, + progress_bar, + previous_meetings, + no_cluster_agents, + val_cluster, + manuals, + random, run_round=functools.partial(run_round, pareto_swaps=pareto_swaps), ) @@ -126,17 +165,20 @@ def algorithm_hermes(participants: np.ndarray[int], return AllocatorResult(final_results2) -def pareto_swaps(shuffled_pids, - cluster_individuals, - cluster_table_index, - temp_allocations, - people, - cats_diverse, - manual_pids, - previous_meetings, - m_data, - pareto_probs, - random): +def pareto_swaps( + shuffled_pids, + cluster_individuals, + cluster_table_index, + temp_allocations, + people, + cats_diverse, + manual_pids, + previous_meetings, + m_data, + pareto_probs, + random, +): + """Select the best swap for a round using HERMES's field thresholds.""" # HERMES's per-field threshold: how far a table's demographic balance # may deviate from the panel-wide ideal before a swap is considered for # that field, controlled by the field's own pareto probability (DREAM @@ -152,24 +194,33 @@ def pareto_swaps(shuffled_pids, table_meeting_evaluations = {} table_demog_evaluations = {} for index, table in enumerate(temp_allocations_update): - table_meeting_evaluations[index] = evaluate_meetings(table, previous_meetings) + table_meeting_evaluations[index] = evaluate_meetings( + table, previous_meetings + ) table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) - + temp_allocations_update, + index, + people, + cats_diverse, + m_data, + threshold_func=threshold_func, + ) for pid in shuffled_pids: - for index, table in enumerate(temp_allocations_update): if pid in table: table_no = index - pid_info = {key: people[pid][key] - for key in people[pid] if key in cats_diverse} + pid_info = { + key: people[pid][key] for key in people[pid] if key in cats_diverse + } candidate_demogs = {} for demog in cats_diverse: - candidate_demogs[demog] = table_demog_evaluations[table_no][1][demog][pid_info[demog]] + candidate_demogs[demog] = table_demog_evaluations[table_no][1][ + demog + ][pid_info[demog]] candidate_profiles = generate_combinations(candidate_demogs, pid_info) candidate_swaps = {} @@ -178,22 +229,27 @@ def pareto_swaps(shuffled_pids, # their field ID) that specific swap would pareto-improve. demog_scores: dict[int, set[int]] = {} - for profile in candidate_profiles: if pid in cluster_individuals: - candidate_swap_tables = [x for x in table_demog_evaluations if ( - x != table_no) and (x in cluster_table_index)] + candidate_swap_tables = [ + x + for x in table_demog_evaluations + if (x != table_no) and (x in cluster_table_index) + ] else: candidate_swap_tables = [ - x for x in table_demog_evaluations if x != table_no] + x for x in table_demog_evaluations if x != table_no + ] for candidate_table in candidate_swap_tables: demog_pareto_fields = set() pareto_score = 0 pareto_profile = table_demog_evaluations[candidate_table][1] table_valid = True for index, demog in enumerate(pareto_profile): - - if pid_info[demog] in pareto_profile[demog][profile[index]]: + if ( + pid_info[demog] + in pareto_profile[demog][profile[index]] + ): pareto_score += 1 demog_pareto_fields.add(demog) @@ -202,55 +258,98 @@ def pareto_swaps(shuffled_pids, break if table_valid: if pid in cluster_individuals: - for swap_pid in temp_allocations_update[candidate_table]: + for swap_pid in temp_allocations_update[ + candidate_table + ]: if swap_pid not in manual_pids: - if tuple(people[swap_pid][key] for key in people[swap_pid] if - key in cats_diverse) == profile: - candidate_swaps[swap_pid] = pareto_score + \ - candidate_profiles[profile] - - demog_scores.setdefault(swap_pid, set()).update(demog_pareto_fields) + if ( + tuple( + people[swap_pid][key] + for key in people[swap_pid] + if key in cats_diverse + ) + == profile + ): + candidate_swaps[swap_pid] = ( + pareto_score + + candidate_profiles[profile] + ) + + demog_scores.setdefault( + swap_pid, set() + ).update(demog_pareto_fields) else: - for swap_pid in temp_allocations_update[candidate_table]: + for swap_pid in temp_allocations_update[ + candidate_table + ]: if swap_pid not in cluster_individuals: if swap_pid not in manual_pids: - if tuple(people[swap_pid][key] for key in people[swap_pid] if - key in cats_diverse) == profile: - - - - candidate_swaps[swap_pid] = pareto_score + \ - candidate_profiles[profile] - - demog_scores.setdefault(swap_pid, set()).update(demog_pareto_fields) - + if ( + tuple( + people[swap_pid][key] + for key in people[swap_pid] + if key in cats_diverse + ) + == profile + ): + candidate_swaps[swap_pid] = ( + pareto_score + + candidate_profiles[profile] + ) + + demog_scores.setdefault( + swap_pid, set() + ).update(demog_pareto_fields) if len(candidate_swaps) == 0: continue candidate_meetings = {} for swap in candidate_swaps: - candidate_meetings[swap] = evaluate_swap(pid, swap, temp_allocations_update, table_meeting_evaluations, - previous_meetings) - - candidate_swaps = {key: value for key, value in candidate_swaps.items() if ( - candidate_swaps[key] > 0) or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0)} + candidate_meetings[swap] = evaluate_swap( + pid, + swap, + temp_allocations_update, + table_meeting_evaluations, + previous_meetings, + ) + + candidate_swaps = { + key: value + for key, value in candidate_swaps.items() + if (candidate_swaps[key] > 0) + or (candidate_swaps[key] == 0 and candidate_meetings[key] > 0) + } if len(candidate_swaps) == 0: continue distinct_candidates = {} for distinct_value in {value for value in candidate_swaps.values()}: - distinct_keys = { - key for key, value in candidate_swaps.items() if value == distinct_value} + key + for key, value in candidate_swaps.items() + if value == distinct_value + } max_meetings = max( - value for key, value in candidate_meetings.items() if key in distinct_keys) - - distinct_candidates.update({key: value for key, value in candidate_swaps.items( - ) if (value == distinct_value) and (candidate_meetings[key] == max_meetings)}) - distinct_meetings = {key: value for key, value in candidate_meetings.items( - ) if key in distinct_candidates} + value + for key, value in candidate_meetings.items() + if key in distinct_keys + ) + + distinct_candidates.update( + { + key: value + for key, value in candidate_swaps.items() + if (value == distinct_value) + and (candidate_meetings[key] == max_meetings) + } + ) + distinct_meetings = { + key: value + for key, value in candidate_meetings.items() + if key in distinct_candidates + } reverse_mapping = {} for key, value in distinct_candidates.items(): if value not in reverse_mapping: @@ -260,12 +359,19 @@ def pareto_swaps(shuffled_pids, for value, keys in reverse_mapping.items(): final_candidates[random.choice(keys)] = value final_meetings = { - key: value for key, value in distinct_meetings.items() if key in final_candidates} + key: value + for key, value in distinct_meetings.items() + if key in final_candidates + } keys_to_remove = set() for key in final_meetings.keys(): - if any(final_meetings[other_key] >= final_meetings[key] and final_candidates[other_key] > final_candidates[ - key] for other_key in final_meetings.keys() if other_key != key): + if any( + final_meetings[other_key] >= final_meetings[key] + and final_candidates[other_key] > final_candidates[key] + for other_key in final_meetings.keys() + if other_key != key + ): keys_to_remove.add(key) for key in keys_to_remove: del final_meetings[key] @@ -276,10 +382,14 @@ def pareto_swaps(shuffled_pids, # back to the overall maximum if it improves none, e.g. it was # accepted purely for its meeting-uniqueness score). def pareto_prob_for(k): - relevant = [pareto_probs[field_id] for field_id in demog_scores.get(k, ())] + relevant = [ + pareto_probs[field_id] for field_id in demog_scores.get(k, ()) + ] return max(relevant) if relevant else max(pareto_probs.values()) - final_swap = select_key(final_candidates, final_meetings, pareto_prob_for, random) + final_swap = select_key( + final_candidates, final_meetings, pareto_prob_for, random + ) if final_swap == None: continue @@ -288,15 +398,25 @@ def pareto_prob_for(k): swap_table = index temp_allocations_update[table_no] = [ - final_swap if x == pid else x for x in temp_allocations_update[table_no]] + final_swap if x == pid else x + for x in temp_allocations_update[table_no] + ] temp_allocations_update[swap_table] = [ - pid if x == final_swap else x for x in temp_allocations_update[swap_table]] + pid if x == final_swap else x + for x in temp_allocations_update[swap_table] + ] for index in [table_no, swap_table]: table_meeting_evaluations[index] = evaluate_meetings( - temp_allocations_update[index], previous_meetings) + temp_allocations_update[index], previous_meetings + ) table_demog_evaluations[index] = evaluate_demographics( - temp_allocations_update, index, people, cats_diverse, m_data, threshold_func=threshold_func) - + temp_allocations_update, + index, + people, + cats_diverse, + m_data, + threshold_func=threshold_func, + ) return temp_allocations_update diff --git a/groupselect/algorithms/algorithm_legacy.py b/groupselect/algorithms/algorithm_legacy.py index ec68444..05547fc 100644 --- a/groupselect/algorithms/algorithm_legacy.py +++ b/groupselect/algorithms/algorithm_legacy.py @@ -1,47 +1,61 @@ +"""Legacy allocation algorithm.""" + from typing import Callable import numpy as np from groupselect.field_mode import FieldMode -from groupselect.allocation import Allocation, ParticipantGroup, AllocationEnsemble, AllocatorResult +from groupselect.allocation import ( + Allocation, + ParticipantGroup, + AllocationEnsemble, + AllocatorResult, +) # Legacy algorithm -def algorithm_legacy(participants: np.ndarray[int], - fields: dict[int, FieldMode], - groups: list[(int, int)], - manuals: dict[int, int], - progress_func: None | Callable = None, - n_attempts: int = 100, - seed: None | int = None) -> AllocatorResult: - +def algorithm_legacy( + participants: np.ndarray[int], + fields: dict[int, FieldMode], + groups: list[(int, int)], + manuals: dict[int, int], + progress_func: None | Callable = None, + n_attempts: int = 100, + seed: None | int = None, +) -> AllocatorResult: + """Allocate participants into groups using the legacy algorithm.""" # Check argument: seed if not (seed is None or isinstance(seed, int)): - raise Exception('Random number seed must be integer.') + raise Exception("Random number seed must be integer.") # Try to set the random seed. try: random = np.random.default_rng(seed) except: - raise Exception('Error when creating random number generator.', - 'This may be due to an incorrect seed. Please ' - 'check your input.') + raise Exception( + "Error when creating random number generator.", + "This may be due to an incorrect seed. Please check your input.", + ) # Check argument: n_attempts if not (isinstance(n_attempts, int) and n_attempts > 0): - raise Exception('Argument n_attempts must be positive integer.') + raise Exception("Argument n_attempts must be positive integer.") # Check that there is at least one diversification field defined. if not FieldMode.Diversify in fields.values(): - raise Exception('Error: One diversification field required!', - 'You have to set at least one field that is ' - 'used to diversify people across groups.') + raise Exception( + "Error: One diversification field required!", + "You have to set at least one field that is " + "used to diversify people across groups.", + ) # Reindex the field values such that the index is descending w.r.t. # the number of occurrences of the specific field value. participants = participants.copy() for field_id in fields: - field_vals, field_val_counts = np.unique(participants.T[field_id], return_counts=True) + field_vals, field_val_counts = np.unique( + participants.T[field_id], return_counts=True + ) mapping_dict = dict(zip(field_vals, field_val_counts.argsort())) mapping_func = np.vectorize(mapping_dict.get) participants.T[field_id] = mapping_func(participants.T[field_id]) @@ -49,8 +63,7 @@ def algorithm_legacy(participants: np.ndarray[int], # Generate AllocationEnsemble `n_attempts` times, where `n_attempts` # is an externally given argument. allocation_attempts: list[AllocationEnsemble] = [ - AllocationEnsemble() - for _ in range(n_attempts) + AllocationEnsemble() for _ in range(n_attempts) ] for n, ensemble in enumerate(allocation_attempts): if progress_func is not None: @@ -80,11 +93,12 @@ def algorithm_legacy(participants: np.ndarray[int], # Sample `n_allocation` allocations and repeat that `n_attempts` times. allocation_samples: list[AllocationEnsemble] = [ - AllocationEnsemble() - for _ in range(n_attempts) + AllocationEnsemble() for _ in range(n_attempts) ] for a_id in range(len(groups)): - for sample, choice in zip(allocation_samples, random.choice(n_attempts, n_attempts)): + for sample, choice in zip( + allocation_samples, random.choice(n_attempts, n_attempts) + ): sample.append(allocation_attempts[choice][a_id]) # Select the sample with maximum number of meetings. @@ -96,30 +110,34 @@ def algorithm_legacy(participants: np.ndarray[int], # Create AllocatorResult from sample max and return return AllocatorResult(ensemble=allocation_sample_max) + # Actually run the calculation for given number of participants # per groups, number of groups, and participants' data. -def _allocate_legacy_once(participants: np.ndarray[int], - fields: dict[int, FieldMode], - n_gr: int, - n_ppgr: int, - manuals: dict[int, int]) -> Allocation: +def _allocate_legacy_once( + participants: np.ndarray[int], + fields: dict[int, FieldMode], + n_gr: int, + n_ppgr: int, + manuals: dict[int, int], +) -> Allocation: # Generate empty allocations. - allocation = Allocation( - ParticipantGroup() - for _ in range(n_gr) - ) + allocation = Allocation(ParticipantGroup() for _ in range(n_gr)) # Allocate manuals for m_p_id, m_g_id in manuals.items(): # Find first empty spot allocation[m_g_id].append(m_p_id) - # Split fields into clustering and diversification fields. Order participant IDs - # by clustering and diversification field values. + # Split fields into clustering and diversification fields. Order + # participant IDs by clustering and diversification field values. fields_cluster = [k for k, v in fields.items() if v == FieldMode.Cluster] - fields_diversify = [k for k, v in fields.items() if v == FieldMode.Diversify] - p_ids_ordered = np.lexsort(participants[:, (fields_cluster + fields_diversify)[::-1]].T) + fields_diversify = [ + k for k, v in fields.items() if v == FieldMode.Diversify + ] + p_ids_ordered = np.lexsort( + participants[:, (fields_cluster + fields_diversify)[::-1]].T + ) # Loop over participants and allocate to a group. for p_id, p_details in zip(p_ids_ordered, participants[p_ids_ordered]): @@ -137,19 +155,20 @@ def _allocate_legacy_once(participants: np.ndarray[int], return allocation + # Add a participant to a group -def _allocate_person(p_id: int, - p_details: np.ndarray[int], - allocation: Allocation, - participants: np.ndarray[int], - fields_cluster: list[int], - fields_diversify: list[int], - n_ppgr: int): +def _allocate_person( + p_id: int, + p_details: np.ndarray[int], + allocation: Allocation, + participants: np.ndarray[int], + fields_cluster: list[int], + fields_diversify: list[int], + n_ppgr: int, +): # List of groups to choose from is initially list of non-full groups. groups_list = [ - g_id - for g_id, group in enumerate(allocation) - if len(group) < n_ppgr + g_id for g_id, group in enumerate(allocation) if len(group) < n_ppgr ] # Loop over clustering fields. superior_field_filters = {} @@ -165,25 +184,31 @@ def _allocate_person(p_id: int, # Determine how often this field value occurs across groups. field_value_counts = { - g_id: _count_categories(allocation[g_id], field_id, field_val, participants) + g_id: _count_categories( + allocation[g_id], field_id, field_val, participants + ) for g_id in groups_list } - # Create a temporary list of potential groups to focus on. To begin with, this list - # contains all groups that already contain at least one participant of the same - # field value. + # Create a temporary list of potential groups to focus on. To + # begin with, this list contains all groups that already contain + # at least one participant of the same field value. groups_list_tmp = [ - g_id - for g_id in groups_list - if field_value_counts[g_id] > 0 + g_id for g_id in groups_list if field_value_counts[g_id] > 0 ] - # Then check if the temporary list of groups is enough to accommodate all participants with - # this specific field value. If not, then keep adding one more group, which is determined + # Then check if the temporary list of groups is enough to + # accommodate all participants with this specific field value. + # If not, then keep adding one more group, which is determined # from all groups from groups_list - spaces_required = _number_of_people_filtered(participants, superior_field_filters | {field_id: field_val}) + spaces_required = _number_of_people_filtered( + participants, superior_field_filters | {field_id: field_val} + ) groups_options = None - while sum((n_ppgr - len(allocation[g_id])) for g_id in groups_list_tmp) < spaces_required: + while ( + sum((n_ppgr - len(allocation[g_id])) for g_id in groups_list_tmp) + < spaces_required + ): groups_options = groups_options or sorted( [g_id for g_id in groups_list if g_id not in groups_list_tmp], key=lambda g_id: len(allocation[g_id]), @@ -192,15 +217,18 @@ def _allocate_person(p_id: int, groups_list = groups_list_tmp superior_field_filters[field_id] = field_val - # Loop over diversification fields. for field_id in fields_diversify: field_val = p_details[field_id] field_value_counts = { - g_id: _count_categories(allocation[g_id], field_id, field_val, participants) + g_id: _count_categories( + allocation[g_id], field_id, field_val, participants + ) for g_id in groups_list } - field_val_counts_min = min(field_value_counts.items(), key=lambda x: x[1])[1] + field_val_counts_min = min( + field_value_counts.items(), key=lambda x: x[1] + )[1] groups_list = [ g_id for g_id in groups_list @@ -210,9 +238,7 @@ def _allocate_person(p_id: int, # Select groups with least number of participants group_size_min = min(len(allocation[g_id]) for g_id in groups_list) groups_list = [ - g_id - for g_id in groups_list - if len(allocation[g_id]) == group_size_min + g_id for g_id in groups_list if len(allocation[g_id]) == group_size_min ] # Add participant to first group from the list of groups that remain. @@ -221,9 +247,24 @@ def _allocate_person(p_id: int, return + # Count number of occurrences of field value in a participant group. -def _count_categories(group: ParticipantGroup, field_id: int, field_val: int, participants: np.ndarray[int]): - return sum(1 for p_id in group if participants[p_id][field_id] == field_val) +def _count_categories( + group: ParticipantGroup, + field_id: int, + field_val: int, + participants: np.ndarray[int], +): + return sum( + 1 for p_id in group if participants[p_id][field_id] == field_val + ) -def _number_of_people_filtered(participants: np.ndarray[int], fields: dict[int, int]): - return (participants.T[list(fields.keys())] == list(fields.values())).all(axis=0).sum() + +def _number_of_people_filtered( + participants: np.ndarray[int], fields: dict[int, int] +): + return ( + (participants.T[list(fields.keys())] == list(fields.values())) + .all(axis=0) + .sum() + ) diff --git a/groupselect/allocate_numpy.py b/groupselect/allocate_numpy.py index f7ef59e..cd93612 100644 --- a/groupselect/allocate_numpy.py +++ b/groupselect/allocate_numpy.py @@ -1,3 +1,5 @@ +"""Allocate participants given as a plain numpy array.""" + from typing import Callable, Iterable from inspect import signature from math import ceil @@ -10,74 +12,99 @@ # Generic function for allocations. -def allocate_numpy(participants: np.ndarray[int], - fields: dict[int, FieldMode], - n_part_per_group: int | Iterable[int], - manuals: None | dict[int, int] = None, - algorithm: Algorithm | str = Algorithm.Legacy, - progress_func: None | Callable = None, - settings: None | dict = None) -> AllocatorResult: - +def allocate_numpy( + participants: np.ndarray[int], + fields: dict[int, FieldMode], + n_part_per_group: int | Iterable[int], + manuals: None | dict[int, int] = None, + algorithm: Algorithm | str = Algorithm.Legacy, + progress_func: None | Callable = None, + settings: None | dict = None, +) -> AllocatorResult: + """Allocate participants into groups using the chosen algorithm.""" # Check arguments: participants. if not np.issubdtype(participants.dtype, np.integer): - raise Exception('Argument participants must only contain integers.') + raise Exception("Argument participants must only contain integers.") if not participants.ndim == 2: - raise Exception('Argument participants must be 2-dimensional array.') + raise Exception("Argument participants must be 2-dimensional array.") # Define constants from participants' data. n_participants = participants.shape[0] n_fields = participants.shape[1] # Check argument n_part_per_group - if not ((isinstance(n_part_per_group, int) and 0 < n_part_per_group <= n_participants) or - (isinstance(n_part_per_group, Iterable) and - all(isinstance(n, int) and 0 < n <= n_participants for n in n_part_per_group))): - raise Exception('Number of participants per group must be a positive integer less' - 'than the total number of participants or a list of such integers.') + if not ( + ( + isinstance(n_part_per_group, int) + and 0 < n_part_per_group <= n_participants + ) + or ( + isinstance(n_part_per_group, Iterable) + and all( + isinstance(n, int) and 0 < n <= n_participants + for n in n_part_per_group + ) + ) + ): + raise Exception( + "Number of participants per group must be a positive integer " + "less than the total number of participants or a list of " + "such integers." + ) n_part_per_group = ( [n_part_per_group] - if isinstance(n_part_per_group, int) else - list(n_part_per_group) + if isinstance(n_part_per_group, int) + else list(n_part_per_group) ) - # Compute number of groups per allocation from number of participants per group. + # Compute number of groups per allocation from number of + # participants per group. groups = [ - (ceil(n_participants / n_ppgr), n_ppgr) - for n_ppgr in n_part_per_group + (ceil(n_participants / n_ppgr), n_ppgr) for n_ppgr in n_part_per_group ] # Check arguments: manuals if manuals is not None: - if not all(isinstance(m_p_id, int) and - 0 <= m_p_id < n_participants - for m_p_id in manuals): - raise Exception('IDs of manually allocated participants must ' - 'be integers within range of number of ' - 'participants.') - if not all(isinstance(m_g_id, int) and - 0 <= m_g_id < n_gr - for n_gr, n_ppgr in groups - for m_g_id in manuals.values()): - raise Exception('Group IDs of manual allocations must be ' - 'within range of requested group sizes.') + if not all( + isinstance(m_p_id, int) and 0 <= m_p_id < n_participants + for m_p_id in manuals + ): + raise Exception( + "IDs of manually allocated participants must " + "be integers within range of number of " + "participants." + ) + if not all( + isinstance(m_g_id, int) and 0 <= m_g_id < n_gr + for n_gr, n_ppgr in groups + for m_g_id in manuals.values() + ): + raise Exception( + "Group IDs of manual allocations must be " + "within range of requested group sizes." + ) else: manuals = {} # Check arguments: fields for f_id, f_usage in fields.items(): if not (isinstance(f_id, int) and 0 <= f_id < n_fields): - raise Exception('Keys of dict containing field usage modes ' - 'must be positive integers and less than the ' - 'number of fields provided for participants.') + raise Exception( + "Keys of dict containing field usage modes " + "must be positive integers and less than the " + "number of fields provided for participants." + ) if isinstance(f_usage, str): try: - fields[f_id] = next(f for f in FieldMode - if f.name.casefold() == f_usage.casefold()) + fields[f_id] = next( + f + for f in FieldMode + if f.name.casefold() == f_usage.casefold() + ) except StopIteration: raise Exception(f"Unknown field usage type: {f_usage}") - if not all(isinstance(f_usage, FieldMode) - for f_usage in fields.values()): - raise Exception('Field usage must be valid usage type.') + if not all(isinstance(f_usage, FieldMode) for f_usage in fields.values()): + raise Exception("Field usage must be valid usage type.") for f_id in range(n_fields): if f_id not in fields: fields[f_id] = FieldMode.Ignore @@ -85,15 +112,22 @@ def allocate_numpy(participants: np.ndarray[int], # Check arguments: algorithm if isinstance(algorithm, str): try: - algorithm = next(a for a in Algorithm - if a.name.casefold() == algorithm.casefold()) + algorithm = next( + a + for a in Algorithm + if a.name.casefold() == algorithm.casefold() + ) except StopIteration: - raise Exception(f"Unknown algorithm type: {algorithm}. Please " - 'choose from: ' + ', '.join(a.name for a in Algorithm)) + raise Exception( + f"Unknown algorithm type: {algorithm}. Please " + "choose from: " + ", ".join(a.name for a in Algorithm) + ) if not (isinstance(algorithm, Algorithm)): - raise Exception('Argument algorithm must be a valid ' - 'algorithm. Please choose from: ' - + ', '.join(a.name for a in Algorithm)) + raise Exception( + "Argument algorithm must be a valid " + "algorithm. Please choose from: " + + ", ".join(a.name for a in Algorithm) + ) # Initialise empty settings dict. if settings is None: @@ -111,11 +145,7 @@ def allocate_numpy(participants: np.ndarray[int], groups=groups, manuals=manuals, progress_func=progress_func, - **{ - k: v - for k, v in settings.items() - if k in algorithm_func_args - } + **{k: v for k, v in settings.items() if k in algorithm_func_args}, ) return result diff --git a/groupselect/allocate_pandas.py b/groupselect/allocate_pandas.py index 3aeebb5..f98a4aa 100644 --- a/groupselect/allocate_pandas.py +++ b/groupselect/allocate_pandas.py @@ -1,3 +1,5 @@ +"""Allocate participants given as a pandas DataFrame.""" + import warnings from typing import Callable, Iterable, Hashable @@ -7,42 +9,47 @@ try: import pandas as pd except ImportError: - raise ImportError('Package `pandas` needs to be installed in order to use ' - 'the allocate_pandas module. Please first install ' - '`pandas` via pip or similar.') + raise ImportError( + "Package `pandas` needs to be installed in order to use " + "the allocate_pandas module. Please first install " + "`pandas` via pip or similar." + ) -def allocate_pandas(participants: pd.DataFrame, - fields: dict[Hashable, FieldMode], - n_part_per_group: int | Iterable[int], - manuals: None | dict[int, int] = None, - algorithm: Algorithm | str = Algorithm.Legacy, - progress_func: None | Callable = None, - settings: None | dict = None, - return_full: bool = False) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame, AllocatorResult]: +def allocate_pandas( + participants: pd.DataFrame, + fields: dict[Hashable, FieldMode], + n_part_per_group: int | Iterable[int], + manuals: None | dict[int, int] = None, + algorithm: Algorithm | str = Algorithm.Legacy, + progress_func: None | Callable = None, + settings: None | dict = None, + return_full: bool = False, +) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame, AllocatorResult]: + """Allocate participants of a dataframe into groups.""" # Check that the dataframe index is unique. if not participants.index.is_unique: - raise Exception('Index of dataframe must be unique.') + raise Exception("Index of dataframe must be unique.") # Check that the specified field IDs are among the dataframe columns # and are unequal to predefined names 'group' and 'allocation'. for field_id in fields: if not field_id in participants: raise Exception(f"Field not found as dataframe column: {field_id}") - if field_id in ('group', 'allocation'): - warnings.warn("The column labels 'group' and 'allocation' will be " - "overwritten in the returned allocation result.") + if field_id in ("group", "allocation"): + warnings.warn( + "The column labels 'group' and 'allocation' will be " + "overwritten in the returned allocation result." + ) # Turn fields into categories. - participants = participants.astype({ - field_id: 'category' - for field_id in fields - }) + participants = participants.astype( + {field_id: "category" for field_id in fields} + ) # Obtain codes for participants' data from pandas. - participants_codes = ( - participants[list(fields.keys())] - .apply(lambda col: col.cat.codes) + participants_codes = participants[list(fields.keys())].apply( + lambda col: col.cat.codes ) participants_numpy = participants_codes.to_numpy() fields_numpy = { @@ -72,19 +79,19 @@ def allocate_pandas(participants: pd.DataFrame, [ # This is just to ensure that the two columns allocation # and group will be the first columns of the dataframe. - pd.DataFrame(columns=['allocation', 'group']) - ] + [ - participants - .loc[participants.index[group]] - .reset_index( - names=[f"participant {l}" for l in participants.index.names] - if participants.index.nlevels > 1 else - 'participant' - ) - .assign( - allocation=al_id, - group=gr_id, - ) + pd.DataFrame(columns=["allocation", "group"]) + ] + + [ + participants.loc[participants.index[group]] + .reset_index( + names=[f"participant {l}" for l in participants.index.names] + if participants.index.nlevels > 1 + else "participant" + ) + .assign( + allocation=al_id, + group=gr_id, + ) for al_id, allocation in enumerate(result.ensemble) for gr_id, group in enumerate(allocation) ], @@ -94,11 +101,13 @@ def allocate_pandas(participants: pd.DataFrame, return ret_part ret_groups = pd.DataFrame.from_records( [ - pd.Series({ - 'allocation': al_id, - 'group': gr_id, - 'participant': participants.index[group].tolist(), - }) + pd.Series( + { + "allocation": al_id, + "group": gr_id, + "participant": participants.index[group].tolist(), + } + ) for al_id, allocation in enumerate(result.ensemble) for gr_id, group in enumerate(allocation) ] @@ -106,19 +115,25 @@ def allocate_pandas(participants: pd.DataFrame, return ret_part, ret_groups, result -@pd.api.extensions.register_dataframe_accessor('groupselect') +@pd.api.extensions.register_dataframe_accessor("groupselect") class GroupSelectAccessor: + """Pandas ``.groupselect`` accessor for allocating a DataFrame.""" + def __init__(self, df: pd.DataFrame): + """Bind the accessor to the DataFrame it was accessed on.""" self._df = df - def allocate(self, - fields: dict[Hashable, FieldMode], - n_part_per_group: int | Iterable[int], - manuals: None | dict[int, int] = None, - algorithm: Algorithm | str = Algorithm.Legacy, - progress_func: None | Callable = None, - settings: None | dict = None, - return_full: bool = False) -> pd.DataFrame: + def allocate( + self, + fields: dict[Hashable, FieldMode], + n_part_per_group: int | Iterable[int], + manuals: None | dict[int, int] = None, + algorithm: Algorithm | str = Algorithm.Legacy, + progress_func: None | Callable = None, + settings: None | dict = None, + return_full: bool = False, + ) -> pd.DataFrame: + """Allocate the bound DataFrame's participants into groups.""" return allocate_pandas( participants=self._df, fields=fields, diff --git a/groupselect/allocation.py b/groupselect/allocation.py index d23004d..01569d2 100644 --- a/groupselect/allocation.py +++ b/groupselect/allocation.py @@ -1,3 +1,5 @@ +"""Data structures for allocations and metrics computed over them.""" + from __future__ import annotations from collections import Counter @@ -13,28 +15,37 @@ class ParticipantGroup(list[int]): + """A single group of participant IDs.""" + pass class Allocation(list[ParticipantGroup]): + """A full allocation of participants into groups.""" + pass class AllocationEnsemble(list[Allocation]): + """A collection of allocations, e.g. produced over multiple rounds.""" + def get_pids(self): + """Return the set of all participant IDs across all allocations.""" return { p_id for allocation in self for group in allocation for p_id in group - if p_id==p_id + if p_id == p_id } def calc_total_number_pairs(self): + """Return the number of possible participant pairs.""" pids = self.get_pids() return comb(len(pids), 2) def calc_pair_counts(self) -> Counter: + """Count how often each pair of participants shares a group.""" pair_counts = Counter() for allocation in self: for group in allocation: @@ -43,6 +54,7 @@ def calc_pair_counts(self) -> Counter: return pair_counts def calc_pair_occurrences(self) -> Counter: + """Count how many pairs meet 0, 1, 2, ... times across allocations.""" # Calculate occurrences from pair counter. pair_counts = self.calc_pair_counts() occurrences = Counter(pair_counts.values()) @@ -55,10 +67,12 @@ def calc_pair_occurrences(self) -> Counter: return occurrences def calc_meeting_rel_score(self) -> float: + """Return the share of participant pairs that meet at least once.""" occurrences = self.calc_pair_occurrences() return 1 - occurrences[0] / sum(occurrences.values()) def calc_meeting_norm_score(self) -> float: + """Return the meeting score normalised to its achievable range.""" occurrences = self.calc_pair_occurrences() rel_score = sum(v for k, v in occurrences.items() if k) @@ -71,23 +85,24 @@ def calc_meeting_norm_score(self) -> float: return ( ((rel_score - min_score) / (max_score - min_score)) - if max_score > min_score else - 1.0 + if max_score > min_score + else 1.0 ) def calc_n_meetings_alo(self) -> int: + """Return the total number of distinct meeting partners, summed.""" return sum( - len(p_stats) - for p_id, p_stats in self.calc_meetings().items() + len(p_stats) for p_id, p_stats in self.calc_meetings().items() ) def calc_meetings(self) -> dict[int, dict[int, int]]: + """Return how often each participant met every other one.""" p_ids = { p_id for allocation in self for group in allocation for p_id in group - if p_id==p_id + if p_id == p_id } meetings = {} @@ -127,11 +142,13 @@ def _calc_diversity_score_single_field(self, values_field: np.array): return ret / len(self) - def calc_diversity_score(self, participants_data: np.ndarray[int] | pd.DataFrame) -> float: + def calc_diversity_score( + self, participants_data: np.ndarray[int] | pd.DataFrame + ) -> float: + """Return the summed diversity score across all fields.""" if pd is not None and isinstance(participants_data, pd.DataFrame): participants_data = ( - participants_data - .astype("category") + participants_data.astype("category") .apply(lambda col: col.cat.codes) .to_numpy() ) @@ -144,5 +161,8 @@ def calc_diversity_score(self, participants_data: np.ndarray[int] | pd.DataFrame class AllocatorResult: + """Result of an allocator run, wrapping the resulting ensemble.""" + def __init__(self, ensemble: None | AllocationEnsemble = None): + """Store the allocation ensemble produced by the allocator.""" self.ensemble = ensemble diff --git a/groupselect/examples/__init__.py b/groupselect/examples/__init__.py index 5521401..bb90640 100644 --- a/groupselect/examples/__init__.py +++ b/groupselect/examples/__init__.py @@ -1,29 +1,32 @@ +"""Example participant datasets loaded from CSV files for demos and tests.""" + from pathlib import Path try: import pandas as pd + HAS_PANDAS: bool = True except ImportError: pd = None HAS_PANDAS: bool = False -DATA_DIR = Path(__file__).parent / 'data' +DATA_DIR = Path(__file__).parent / "data" if HAS_PANDAS: example_data_np = {} example_data_pd = {} - for file in DATA_DIR.glob('*.csv'): + for file in DATA_DIR.glob("*.csv"): basename = file.stem example_data_pd[basename] = pd.read_csv(file) example_data_pd[basename].set_index( - 'ID' if 'ID' in example_data_pd[basename] else 'name', + "ID" if "ID" in example_data_pd[basename] else "name", inplace=True, ) example_data_np[basename] = ( example_data_pd[basename] - .astype('category') + .astype("category") .apply(lambda col: col.cat.codes) .to_numpy() ) diff --git a/groupselect/field_mode.py b/groupselect/field_mode.py index afde5ec..aded307 100644 --- a/groupselect/field_mode.py +++ b/groupselect/field_mode.py @@ -1,8 +1,12 @@ +"""Modes controlling how a participant field is treated during allocation.""" + from enum import Enum, unique @unique class FieldMode(Enum): + """How a participant field should be used when forming groups.""" + Ignore = 0 Diversify = 1 Cluster = 2 diff --git a/groupselect/tests/test_load_examples.py b/groupselect/tests/test_load_examples.py index 5a56253..9be41f6 100644 --- a/groupselect/tests/test_load_examples.py +++ b/groupselect/tests/test_load_examples.py @@ -1,7 +1,10 @@ +"""Tests for loading the example datasets.""" + import pytest def test_load_examples(): + """Check that the examples package can be imported without error.""" try: import groupselect.examples except Exception as ex: diff --git a/groupselect/tests/test_pandas.py b/groupselect/tests/test_pandas.py index 5390e62..6694498 100644 --- a/groupselect/tests/test_pandas.py +++ b/groupselect/tests/test_pandas.py @@ -1,3 +1,5 @@ +"""Tests for the pandas ``.groupselect`` accessor.""" + import pytest import pandas as pd @@ -6,13 +8,24 @@ def test_philipps_example_data(): - df = example_data_pd['philipps_example_data'] - for fields in ({'age': 'diversify', 'gender': 'diversify', - 'photo consent': 'cluster'},): - for n_part_per_group in (3 * [6], 4 * [8],): + """Allocate Philipp's example dataset and print the result.""" + df = example_data_pd["philipps_example_data"] + for fields in ( + { + "age": "diversify", + "gender": "diversify", + "photo consent": "cluster", + }, + ): + for n_part_per_group in ( + 3 * [6], + 4 * [8], + ): res = df.groupselect.allocate( fields=fields, n_part_per_group=n_part_per_group, ) - with pd.option_context('display.max_rows', None, 'display.max_columns', None): + with pd.option_context( + "display.max_rows", None, "display.max_columns", None + ): print(res) diff --git a/pyproject.toml b/pyproject.toml index e7fbfdf..4334968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,15 @@ pandas = ["pandas>=2.0"] [dependency-groups] dev = [ "pytest>=9.0.0", + "ruff>=0.16.5", ] + +[tool.ruff] +line-length = 79 + +[tool.ruff.lint] +select = ["D"] +extend-select = ["E501"] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" From 5fc8a3b19cf2268ee4d153e09f72eabb69b72b56 Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 00:32:25 +0200 Subject: [PATCH 25/26] Added CI --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..619ddae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + # 1) Run the test suite against every supported Python version. + tests: + name: Tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync --all-extras + + - name: Run tests + run: | + uv run python -m pytest -v + + # 2) Lint and formatting checks with ruff. + lint: + name: Lint & format (ruff) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv sync --group dev + + - name: Ruff lint + run: | + uv run ruff check . + + - name: Ruff format check + run: | + uv run ruff format --check . From 0d4d1c531cd77d4de2e8153b6102b6138503b83f Mon Sep 17 00:00:00 2001 From: Philipp Verpoort Date: Mon, 31 Aug 2026 00:37:03 +0200 Subject: [PATCH 26/26] Added build system and moved tests --- .github/workflows/ci.yml | 2 +- pyproject.toml | 4 ++++ {groupselect/tests => tests}/test_load_examples.py | 0 {groupselect/tests => tests}/test_pandas.py | 0 4 files changed, 5 insertions(+), 1 deletion(-) rename {groupselect/tests => tests}/test_load_examples.py (100%) rename {groupselect/tests => tests}/test_pandas.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 619ddae..e91ec1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Run tests run: | - uv run python -m pytest -v + uv run pytest -v # 2) Lint and formatting checks with ruff. lint: diff --git a/pyproject.toml b/pyproject.toml index 4334968..8499575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "groupselect" version = "0.1.0" diff --git a/groupselect/tests/test_load_examples.py b/tests/test_load_examples.py similarity index 100% rename from groupselect/tests/test_load_examples.py rename to tests/test_load_examples.py diff --git a/groupselect/tests/test_pandas.py b/tests/test_pandas.py similarity index 100% rename from groupselect/tests/test_pandas.py rename to tests/test_pandas.py