diff --git a/experiments/simulations_course_match.py b/experiments/simulations_course_match.py new file mode 100644 index 0000000..25bd875 --- /dev/null +++ b/experiments/simulations_course_match.py @@ -0,0 +1,182 @@ +import logging +import numpy as np +import matplotlib.pyplot as plt +from fairpyx.algorithms.almost_egalitarian import almost_egalitarian_allocation +from fairpyx.satisfaction import AgentBundleValueMatrix +from fairpyx.adaptors import divide, divide_with_priorities +from fairpyx.algorithms.course_match.main_course_match import course_match_algorithm, check_envy +from fairpyx.instances import Instance +import time + +# Set up logging +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) # Set to logging.INFO to reduce verbosity +ch = logging.StreamHandler() +ch.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +ch.setFormatter(formatter) +logger.addHandler(ch) + +# Define a function to compute and collect metrics +def compute_metrics(satisfaction_matrix): + metrics = { + "Max Envy": satisfaction_matrix.max_envy(), + "Mean Envy": satisfaction_matrix.mean_envy(), + "Count Agents with Top Rank 1": satisfaction_matrix.count_agents_with_top_rank(1), + "Count Agents with Top Rank 2": satisfaction_matrix.count_agents_with_top_rank(2), + } + return metrics + +# Function to make a budget +def make_budget(num_of_agents: int = 30, low: int = 2, high: int = 2.1, agent_name_template: str = "s{index}"): + logger.debug(f"Creating budget for {num_of_agents} agents with budgets ranging from {low} to {high}") + budget_list = np.random.uniform(low=low, high=high, size=num_of_agents) + agents = [agent_name_template.format(index=i + 1) for i in range(num_of_agents)] + budget = {agent: agent_budget for agent, agent_budget in zip(agents, budget_list)} + return budget + +def create_random_sublists(original_array: list, num_divisions: int = None, random_seed: int = None): + logger.debug(f"Creating random sublists from array of length {len(original_array)} with {num_divisions} divisions") + if random_seed is None: + random_seed = np.random.randint(1, 2**31) + np.random.seed(random_seed) + + # Generate the number of divisions + if num_divisions is None: + num_divisions = np.random.randint(1, len(original_array) + 1) + + # Shuffle the original array + shuffled_array = np.random.permutation(original_array) + + # Determine sizes of each sublist + sublist_sizes = [] + remaining_elements = len(original_array) + + for _ in range(num_divisions - 1): + # Random size for each sublist + size = np.random.randint(1, remaining_elements - (num_divisions - len(sublist_sizes) - 1) + 1) + sublist_sizes.append(size) + remaining_elements -= size + + # Add the remaining elements as the last sublist size + sublist_sizes.append(remaining_elements) + + # Create the sublists + sublists = [] + current_index = 0 + + for size in sublist_sizes: + sublist = shuffled_array[current_index:current_index + size] + sublists.append(sublist.tolist()) + current_index += size + + return sublists + +# Plot the metrics +def plot_metrics(metrics_all, metric_name, algorithm_names, title): + logger.debug(f"Plotting metrics for {metric_name}") + plt.figure(figsize=(12, 6)) + for metrics, algo_name in zip(metrics_all, algorithm_names): + values = [m[metric_name] for m in metrics] + plt.plot(range(len(values)), values, label=algo_name) + + plt.title(title) + plt.xlabel('Iterations') + plt.ylabel(metric_name) + plt.legend() + plt.savefig(f'{title}_{metric_name}.png') + plt.close() + + +# Plot the execution times +def plot_execution_times(times_all, algorithm_names, title): + logger.debug("Plotting execution times") + plt.figure(figsize=(12, 6)) + for times, algo_name in zip(times_all, algorithm_names): + plt.plot(range(len(times)), times, label=algo_name) + + plt.title(title) + plt.xlabel('Iterations') + plt.ylabel('Execution Time (s)') + plt.legend() + plt.savefig(f'{title}_execution_time.png') + plt.close() + +def simulation_with_diffrent_distributions(list_num_items_capacity: list, list_num_agents: list, divide_type='default'): + logger.info(f"Starting simulation with distributions: {list_num_items_capacity} items capacity, {list_num_agents} agents, divide_type={divide_type}") + metrics_cm_all = [] + metrics_al_eg_all = [] + times_cm = [] + times_al_eg = [] + + for num_agents, item_capacity in zip(list_num_agents, list_num_items_capacity): + logger.debug(f"Generating random instance for {num_agents} agents and {item_capacity} items capacity") + random_instance = Instance.random_uniform( + num_of_agents=num_agents, + num_of_items=10, + agent_capacity_bounds=[5, 5], + item_capacity_bounds=[item_capacity, item_capacity], + item_base_value_bounds=[1, 100], + item_subjective_ratio_bounds=[0.5, 1.5], + normalized_sum_of_values=100, + random_seed=1 + ) + + # Budget + budget = make_budget(num_of_agents=num_agents) + + # Perform allocations using different algorithms with time checks + start_time = time.time() + if divide_type == 'default': + logger.debug("Performing allocation using Course Match Algorithm without priorities") + alloc_cm = divide(algorithm=course_match_algorithm, instance=random_instance, budget=budget) + check_envy(alloc_cm, random_instance) + elif divide_type == 'with_priorities': + logger.debug("Performing allocation using Course Match Algorithm with priorities") + priorities_list = create_random_sublists(original_array=list(random_instance.agents), random_seed=1) + alloc_cm = divide(algorithm=course_match_algorithm, instance=random_instance, budget=budget, priorities_student_list=priorities_list) + check_envy(alloc_cm, random_instance) + times_cm.append(time.time() - start_time) + + start_time = time.time() + if divide_type == 'default': + logger.debug("Performing allocation using Almost Egalitarian Allocation without priorities") + alloc_al_eg = divide(algorithm=almost_egalitarian_allocation, instance=random_instance) + check_envy(alloc_cm, random_instance) + elif divide_type == 'with_priorities': + logger.debug("Performing allocation using Almost Egalitarian Allocation with priorities") + alloc_al_eg = divide_with_priorities(algorithm=almost_egalitarian_allocation, instance=random_instance, agent_priority_classes=priorities_list) + check_envy(alloc_cm, random_instance) + times_al_eg.append(time.time() - start_time) + + # Instantiate AgentBundleValueMatrix with the random instance and for both allocations + logger.debug("Instantiating AgentBundleValueMatrix for Course Match allocation") + satisfaction_matrix_cm = AgentBundleValueMatrix(random_instance, alloc_cm, normalized=False) + logger.debug("Instantiating AgentBundleValueMatrix for Almost Egalitarian allocation") + satisfaction_matrix_al_eg = AgentBundleValueMatrix(random_instance, alloc_al_eg, normalized=False) + + metrics_cm = compute_metrics(satisfaction_matrix_cm) + metrics_al_eg = compute_metrics(satisfaction_matrix_al_eg) + + metrics_cm_all.append(metrics_cm) + metrics_al_eg_all.append(metrics_al_eg) + + metric_names = [ + "Max Envy", "Mean Envy", "Count Agents with Top Rank 1", "Count Agents with Top Rank 2" + ] + + algorithm_names = ["Course Match Algorithm", "Almost Egalitarian Allocation"] + + for metric_name in metric_names: + plot_metrics([metrics_cm_all, metrics_al_eg_all], metric_name, algorithm_names, f"Comparison of {metric_name}") + + plot_execution_times([times_cm, times_al_eg], algorithm_names, "Execution Time Comparison") + + +if __name__ == "__main__": + list_num_agents = [num_agents for num_agents in range(90, 330, 30)] + list_num_items_capacity = [items_capacity for items_capacity in range(9, 33, 3)] + + simulation_with_diffrent_distributions(list_num_items_capacity, list_num_agents, divide_type='default') + + simulation_with_diffrent_distributions(list_num_items_capacity, list_num_agents, divide_type='with_priorities') diff --git a/fairpyx/algorithms/__init__.py b/fairpyx/algorithms/__init__.py index 261466d..af1c99c 100644 --- a/fairpyx/algorithms/__init__.py +++ b/fairpyx/algorithms/__init__.py @@ -10,3 +10,5 @@ from fairpyx.algorithms.Optimization_based_Mechanisms.TTC_O import TTC_O_function from fairpyx.algorithms.Optimization_based_Mechanisms.TTC import TTC_function from fairpyx.algorithms.Optimization_based_Mechanisms.optimal_functions import notExceedtheCapacity, numberOfCourses, give_items_according_to_allocation_matrix +from fairpyx.algorithms.course_match.main_course_match import course_match_algorithm + diff --git a/fairpyx/algorithms/course_match/A_CEEI.py b/fairpyx/algorithms/course_match/A_CEEI.py new file mode 100644 index 0000000..567d4e9 --- /dev/null +++ b/fairpyx/algorithms/course_match/A_CEEI.py @@ -0,0 +1,515 @@ +""" +Course Match: A Large-Scale Implementation ofApproximate Competitive Equilibrium fromEqual Incomes for Combinatorial Allocation, +by Eric Budish, Gérard P. Cachon, Judd B. Kessler, Abraham Othman +June 2, 2016 +https://pubsonline.informs.org/doi/epdf/10.1287/opre.2016.1544 + +Programmer: Naama Shiponi and Ben Dabush +Date: 1/6/2024 +""" +import logging +import random +import time +import numpy as np +from fairpyx import Instance, AllocationBuilder +from itertools import combinations + +logger = logging.getLogger(__name__) + +""" +Algorithm 1: Approximate Competitive Equilibrium from Equal Incomes (A-CEEI), finds the best price vector that matches student preferences and course capacities. +""" +def A_CEEI(alloc: AllocationBuilder, budget : dict , time_limit: int = 60,seed = None ) -> dict: + """ + Perform heuristic search to find the best price vector that matches student preferences and course capacities. + + :param allocation: Allocation object. + :param budget (float): Initial budget. + :param time (float): Time limit for the search. + + :return (dict) best price vector. + + :example + + >>> instance = Instance( + ... agent_conflicts = {"Alice": [], "Bob": [], "Tom": []}, + ... item_conflicts = {"c1": [], "c2": [], "c3": []}, + ... agent_capacities = {"Alice": 1, "Bob": 1, "Tom": 1}, + ... item_capacities = {"c1": 1, "c2": 1, "c3": 1}, + ... valuations = {"Alice": {"c1": 100, "c2": 0, "c3": 0}, + ... "Bob": {"c1": 0, "c2": 100, "c3": 0}, + ... "Tom": {"c1": 0, "c2": 0, "c3": 100} + ... }) + >>> budget = {"Alice": 1.0, "Bob": 1.1, "Tom": 1.3} + >>> allocation = AllocationBuilder(instance) + >>> {k: round(v) for k, v in A_CEEI(allocation, budget, 10, 60).items()} + {'c1': 1, 'c2': 1, 'c3': 1} + + + """ + logger.info("Starting A-CEEI algorithm with budget: %s and time limit: %s seconds", budget, time_limit) + + + def initialize_price_vector(budget,seed): + return {k: random.uniform(0, max(budget.values())) for k in alloc.instance.items} + + best_error = float('inf') + best_price_vector = dict() + start_time = time.time() + steps = [0.1, 0.2, 0.3, 0.4, 0.5] # Example step sizes, can be adjusted + + preferred_schedule = find_preferred_schedule_adapter(alloc) + logger.debug("Calling find_preferred_schedule_adapter %s",preferred_schedule) + + counter = 0 + while time.time() - start_time < time_limit : + if seed: + seed+=1 + random.seed(seed) + price_vector = initialize_price_vector(budget,seed) + logger.debug("Initialized price vector: %s", price_vector) + + search_error = alpha(compute_surplus_demand_for_each_course(price_vector, alloc, budget, preferred_schedule)) + logger.debug("Initial search error: %f", search_error) + + tabu_list = [] + c = 0 + while c < 5: + neighbors = find_neighbors(price_vector, alloc, budget, steps, preferred_schedule) + logger.debug("Found %d neighbors : %s", len(neighbors), neighbors) + + while neighbors: + next_price_vector = neighbors.pop(0) + next_demands = compute_surplus_demand_for_each_course(next_price_vector, alloc, budget, preferred_schedule) + + if next_demands not in tabu_list: + break + + if not neighbors: #if there are neighbors is empty + c = 5 + else: + logger.debug("next_price_vector: %f", next_price_vector) + price_vector = next_price_vector + tabu_list.append(next_demands) + logger.debug("add next_demands to tabu_list: %s", next_demands) + current_error = alpha(next_demands) + logger.debug("Current error: %f, Search error: %f", current_error, search_error) + if current_error < search_error: + search_error = current_error + c = 0 + else: + c += 1 + + if current_error < best_error: + best_error = current_error + best_price_vector = price_vector + logger.info("New best price vector found with error: %f", best_error) + logger.info("A-CEEI algorithm completed. Best price vector: %s with error: %f", best_price_vector, best_error) + return best_price_vector + + + +def find_preference_order_for_each_student(valuations:dict, agent_capacities:dict, item_conflicts:dict, agent_conflicts:dict): + """ + Finds, for each student, the complete preference ordering on all possible schedules. + This is a pre-processing step: we compute all preference ordering once, and then use it to find the best schedule that fits the budget. + + :param valuations: Dictionary of valuations. + :param agent_capacities: Dictionary of agent capacities. + :param item_conflicts: Dictionary of item conflicts. + :param agent_conflicts: Dictionary of agent conflicts. + + :return (dict) Dictionary of preferred schedules. + + :example + >>> agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 1} + >>> item_conflicts = {"c1": ["c2"], "c2": ["c1"], "c3": []} + >>> agent_conflicts = {"Alice": ["c2"], "Bob": [], "Tom": []} + >>> valuations = {"Alice": {"c1": 90, "c2": 60, "c3": 50}, "Bob": {"c1": 50, "c2": 81, "c3": 60}, "Tom": {"c1": 100, "c2": 95, "c3": 30}} + >>> find_preference_order_for_each_student(valuations, agent_capacities, item_conflicts, agent_conflicts) + {'Alice': [[1, 0, 1], [1, 0, 0], [0, 0, 1]], 'Bob': [[0, 1, 1], [1, 0, 1], [0, 1, 0], [0, 0, 1], [1, 0, 0]], 'Tom': [[1, 0, 0], [0, 1, 0], [0, 0, 1]]} + """ + + def is_valid_schedule(schedule, item_conflicts, agent_conflicts, agent): + ''' + Check if a schedule is valid. + :param schedule: Dictionary of items in the schedule. + :param item_conflicts: Dictionary of item conflicts. + :param agent_conflicts: Dictionary of agent conflicts. + :param agent: Agent name. + + :return (bool) True if the schedule is valid, False otherwise. + + :example + >>> schedule = {"c1": 1, "c2": 0, "c3": 1} + >>> item_conflicts = {"c1": ["c2"], "c2": ["c1"], "c3": []} + >>> agent_conflicts = {"Alice": ["c2"], "Bob": [], "Tom": []} + >>> is_valid_schedule({"c1": 1, "c2": 0, "c3": 1}, item_conflicts, agent_conflicts, "Alice") + True + >>> is_valid_schedule({"c1": 1, "c2": 1, "c3": 1}, item_conflicts, agent_conflicts, "Alice") + False + ''' + logger.debug("Checking if schedule is valid, schedule: %s , item_conflicts: %s, agent_conflicts: %s, agent: %s ", schedule, item_conflicts, agent_conflicts, agent) + # Check item conflicts + for item, conflicts in item_conflicts.items(): + # print(f"Item: {item}, Conflicts: {conflicts}") # Debug print + if schedule.get(item, 0) == 1: + for conflicted_item in conflicts: + if schedule.get(conflicted_item, 0) == 1: + return False + # Check agent conflicts + for conflicted_item in agent_conflicts.get(agent, []): + # print(f"Agent: {agent}, Conflict: {conflict}") # Debug print + if schedule.get(conflicted_item, 0) == 1: + return False + return True + + + def generate_all_schedules(items, capacity): + ''' + Generate all possible schedules for a given set of items and capacity. + :param items: List of items. + :param capacity: Capacity. + + :return (list) List of all possible schedules. + + :example + >>> items = ["c1", "c2", "c3"] + >>> capacity = 2 + >>> generate_all_schedules(items, capacity) + [{'c1': 1, 'c2': 0, 'c3': 0}, {'c1': 0, 'c2': 1, 'c3': 0}, {'c1': 0, 'c2': 0, 'c3': 1}, {'c1': 1, 'c2': 1, 'c3': 0}, {'c1': 1, 'c2': 0, 'c3': 1}, {'c1': 0, 'c2': 1, 'c3': 1}] + ''' + logger.debug("Generating all possible schedules for items: %s and capacity: %s", items, capacity) + all_schedules = [] + for num_courses_per_agent in range(1, capacity + 1): + for schedule in combinations(items, num_courses_per_agent): + schedule_dict = {item: 1 if item in schedule else 0 for item in items} + if is_valid_schedule(schedule_dict, item_conflicts, agent_conflicts, agent): + all_schedules.append(schedule_dict) + return all_schedules + + logger.info("Finding preference order for each student") + + preferred_schedules = dict() + + for agent in agent_capacities.keys(): + items = valuations[agent].keys() + capacity = agent_capacities[agent] + all_schedules = generate_all_schedules(items, capacity) + logger.debug("All schedules for agent %s: %s", agent, all_schedules) + # Calculate valuations for valid schedules + schedule_valuations = dict() + for schedule in all_schedules: + total_valuation = sum(valuations[agent][item] for item in schedule if schedule[item] == 1) + schedule_valuations[total_valuation] = schedule_valuations.get(total_valuation, []) + schedule_valuations[total_valuation].append([schedule[item] for item in items]) + # Sort the schedules by total valuation in descending order + sorted_valuations = sorted(schedule_valuations.keys(), reverse=True) + + for val in sorted_valuations: + if len(schedule_valuations.get(val)) > 1: + schedule_valuations[val] = sorted(schedule_valuations.get(val), key=lambda x: sum(x), reverse=True) + + # Collect sorted schedules + sorted_schedules = [] + + for val in sorted_valuations: + for schedule in schedule_valuations.get(val): + sorted_schedules.append(schedule) + + preferred_schedules[agent] = sorted_schedules + + logger.info("Preferred schedules: %s", preferred_schedules) + return preferred_schedules + + + +def compute_surplus_demand_for_each_course(price_vector: dict ,alloc: AllocationBuilder, budget : dict, preferred_schedule: dict): + """ + :param price_vector: List of prices. + :param allocation: Allocation object. + :param budget: Dictionary of budgets. + :param preferred_schedule: Dictionary that maps each student to his preference order on schedules. + + :return (dict) Dictionary of course demands. + + :example + >>> instance = Instance( + ... agent_conflicts = {"Alice": [], "Bob": [], "Tom": []}, + ... item_conflicts = {"c1": [], "c2": [], "c3": []}, + ... item_capacities = {"c1": 1, "c2": 2, "c3": 2}, + ... agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 2}, + ... valuations = {"Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70} + ... }) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 2.1, "Tom": 2.3} + >>> preferred_schedule = {'Alice': [[1, 0, 1], [0, 1, 1], [0, 0, 1], [1, 1, 0], [1, 0, 0], [0, 1, 0]], + ... 'Bob': [[1, 1, 0], [1, 0, 1], [0, 1, 1], [1, 0, 0], [0, 1, 0], [0, 0, 1]], + ... 'Tom': [[1, 0, 1], [1, 1, 0], [0, 1, 1], [1, 0, 0], [0, 0, 1], [0, 1, 0]]} + >>> compute_surplus_demand_for_each_course(price_vector,allocation , budget, preferred_schedule) + {'c1': 2, 'c2': -1, 'c3': 0} + """ + logger.info("Calculating course demands") + best_schedules = find_best_schedule(price_vector, budget, preferred_schedule) + sol = np.sum(np.array(best_schedules), axis=0) + # Convert item capacities to a list + item_capacities_list = [alloc.instance.item_capacity(name_item) for name_item in alloc.instance.items] + + # Convert item capacities list to a numpy array + item_capacities_array = np.array(item_capacities_list) + + # Perform the subtraction + result = {name_item: int(sol[i] - item_capacities_array[i]) for i, name_item in enumerate(alloc.instance.items)} + logger.debug("Course demands: %s", result) + return result + + +def find_best_schedule(price_vector: dict, budget : dict, preferred_schedule: dict): + """ + Find the best schedule for a student considering the price vector and the budget. + + :param price_vector: List of prices. + :param budget: Dictionary of budgets. + :param preferred_schedule: Dictionary of preferred schedules. + + :return (list) List of courses in the best schedule. + + :example + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 2.1, "Tom": 2.3} + >>> preferred_schedule = {"Alice": [[1, 0, 1], [0, 1, 1], [1, 1, 0]] , "Bob": [[1, 1, 0], [1, 0, 1], [0, 1, 1]], "Tom": [[1, 0, 1], [1, 1, 0], [0, 1, 1]]} + >>> find_best_schedule(price_vector, budget, preferred_schedule) # {"Alice": "AC", "Bob": "AB", "Tom": "AC"} + [[1, 0, 1], [1, 1, 0], [1, 0, 1]] + + + >>> price_vector = {'c1': 1.2, 'c2': 0.9, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 2.1, "Tom": 2.3} + >>> preferred_schedule = {"Alice": [[1, 0, 1], [0, 1, 1], [1, 1, 0]] , "Bob": [[1, 1, 0], [1, 0, 1], [0, 1, 1]], "Tom": [[1, 0, 1], [1, 1, 0], [0, 1, 1]]} + >>> find_best_schedule(price_vector, budget, preferred_schedule) # {"Alice": "BC", "Bob": "AB", "Tom": "AC"} + [[0, 1, 1], [1, 1, 0], [1, 0, 1]] + + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 1.1, "Tom": 1.3} + >>> preferred_schedule = {"Alice": [[1, 0, 1], [0, 1, 1], [1, 1, 0]] , "Bob": [[0, 1, 0], [0, 0, 1], [1, 0, 0]], "Tom": [[0, 0, 1], [1, 0, 0], [0, 1, 0]]} + >>> find_best_schedule(price_vector, budget, preferred_schedule) # {"Alice": "AB", "Bob": "B", "Tom": "C"} + [[1, 0, 1], [0, 1, 0], [0, 0, 1]] + + """ + logger.info("Finding the best schedule for students") + best_schedule = [] + cuont = 0 + price_array = np.array([price_vector[key] for key in price_vector.keys()]) + for student, schedule in preferred_schedule.items(): + best_schedule.append(np.zeros(len(price_vector))) + sum_of_courses = [i for i in np.sum(schedule * price_array[:, np.newaxis].T, axis=1)] + for i in range(len(sum_of_courses)): + if sum_of_courses[i] <= budget[student]: + best_schedule[cuont] = schedule[i] + break + cuont += 1 + logger.debug("Best schedule: %s", best_schedule) + return best_schedule + + +def alpha(demands: dict): + """ + :param demands: Dictionary of course demands. + + :return (float) Alpha value. + + :example + >>> demands = {"c1": 2, "c2": -1, "c3": 0} + >>> alpha(demands) # sqrt(5) + 2.23606797749979 + + >>> demands = {"c1": 1, "c2": 1, "c3": 1} + >>> alpha(demands) # sqrt(3) + 1.7320508075688772 + + >>> demands = {"c1": 0, "c2": 0, "c3": 0} + >>> alpha(demands) + 0.0 + """ + logger.info("Calculating alpha value for demands: %s", demands) + result = np.sqrt(sum([v**2 for v in demands.values()])) + logger.debug("Alpha value: %f", result) + return result + + +def find_neighbors(price_vector: dict ,alloc: AllocationBuilder, budget : dict, steps: list, preferred_schedule: dict): + """ + :param price_vector: List of prices. + :param allocation: Allocation object. + :param budget: Dictionary of budgets. + :param steps: List of steps. + :param preferred_schedule: Dictionary of preferred schedules. + + :return (list of list) List of neighbors. + + :example + >>> instance = Instance( + ... agent_conflicts = {"Alice": [], "Bob": [], "Tom": []}, + ... item_conflicts = {"c1": [], "c2": [], "c3": []}, + ... agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities = {"c1": 1, "c2": 2, "c3": 2}, + ... valuations = {"Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70} + ... }) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 2.1, "Tom": 2.3} + >>> steps = [0.1, 0.2] + >>> preferred_schedule = {'Alice': [[1, 0, 1], [0, 1, 1], [0, 0, 1], [1, 1, 0], [1, 0, 0], [0, 1, 0]], + ... 'Bob': [[1, 1, 0], [1, 0, 1], [0, 1, 1], [1, 0, 0], [0, 1, 0], [0, 0, 1]], + ... 'Tom': [[1, 0, 1], [1, 1, 0], [0, 1, 1], [1, 0, 0], [0, 0, 1], [0, 1, 0]]} + >>> find_neighbors(price_vector, allocation, budget, steps, preferred_schedule) + [{'c1': 1.2, 'c2': 0.9, 'c3': 1.0}, {'c1': 1.4, 'c2': 0.8, 'c3': 1.0}, {'c1': 1.1, 'c2': 1.0, 'c3': 1.0}, {'c1': 1.0, 'c2': 0.0, 'c3': 1.0}] + + """ + logger.info("Finding neighbors for price vector: %s", price_vector) + demands = compute_surplus_demand_for_each_course(price_vector, alloc, budget, preferred_schedule) + list_of_neighbors = generate_gradient_neighbors(price_vector, demands, steps) + list_of_neighbors.extend(generate_individual_adjustment_neighbors(price_vector, alloc, demands, budget, preferred_schedule)) + + #sort list_of_neighbors dict values by alpha + sorted_neighbors = sorted(list_of_neighbors, key=lambda neighbor: alpha(compute_surplus_demand_for_each_course(neighbor, alloc, budget, preferred_schedule))) + logger.debug("Sorted neighbors: %s", sorted_neighbors) + return sorted_neighbors + + +def generate_individual_adjustment_neighbors(price_vector: dict, alloc: AllocationBuilder, demands: dict, budget : dict , preferred_schedule: dict): + """ + Generate individual adjustment neighbors. + + :param price_vector: List of prices. + :param allocation: Allocation object. + :param demands: Dictionary of course demands. + :param budget: Dictionary of budgets. + :param preferred_schedule: Dictionary of preferred schedules. + + :return (list of list) List of individual adjustment neighbors. + + :example + >>> instance = Instance( + ... agent_conflicts = {"Alice": [], "Bob": [], "Tom": []}, + ... item_conflicts = {"c1": [], "c2": [], "c3": []}, + ... agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities = {"c1": 1, "c2": 2, "c3": 2}, + ... valuations = {"Alice": {"c1": 90, "c2": 60, "c3": 50}, + ... "Bob": {"c1": 57, "c2": 80, "c3": 63}, + ... "Tom": {"c1": 70, "c2": 50, "c3": 95} + ... }) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> budget = {"Alice": 2.0, "Bob": 2.1, "Tom": 2.3} + >>> preferred_schedule = {'Alice': [[1, 1, 0], [1, 0, 1], [0, 1, 1], [1, 0, 0], [0, 1, 0], [0, 0, 1]], + ... 'Bob': [[0, 1, 1], [1, 1, 0], [1, 0, 1], [0, 1, 0], [0, 0, 1], [1, 0, 0]], + ... 'Tom': [[1, 0, 1], [0, 1, 1], [1, 1, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]]} + >>> demands = {'c1': 2, 'c2': -1, 'c3': 0} + >>> generate_individual_adjustment_neighbors(price_vector, allocation, demands, budget, preferred_schedule) + [{'c1': 1.1, 'c2': 1.0, 'c3': 1.0}, {'c1': 1.0, 'c2': 0.0, 'c3': 1.0}] + + """ + logger.info("Generating individual adjustment neighbors") + limit_loop = 100000 + step=0.1 + neighbors = [] + for k in demands.keys(): + if demands[k] == 0: + continue + new_price_vector = price_vector.copy() + new_demands= demands.copy() + counter=0 + if demands[k] > 0: + while (demands == new_demands) and counter < limit_loop : + new_price_vector.update({k: new_price_vector[k] + step}) + new_demands = compute_surplus_demand_for_each_course(new_price_vector, alloc, budget, preferred_schedule) + counter+=1 + elif demands[k] < 0: + new_price_vector.update({k: 0.0}) + if counter < limit_loop: + neighbors.append(new_price_vector.copy()) # Ensure to append a copy + + logger.debug("Individual adjustment neighbors: %s", neighbors) + return neighbors + +def generate_gradient_neighbors(price_vector: dict, demands: dict, steps: list): + """ + Generate gradient neighbors. + + :param price_vector: List of prices. + :param demands: Dictionary of course demands. + :param steps: List of steps. + + :return (list of list) List of gradient neighbors. + + :example + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> demands = {"c1": 2, "c2": -1, "c3": 0} + >>> steps = [0.1, 0.2] + >>> generate_gradient_neighbors(price_vector, demands, steps) + [{'c1': 1.2, 'c2': 0.9, 'c3': 1.0}, {'c1': 1.4, 'c2': 0.8, 'c3': 1.0}] + + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> demands = {"c1": 1, "c2": 1, "c3": 1} + >>> steps = [0.1, 0.2] + >>> generate_gradient_neighbors(price_vector, demands, steps) + [{'c1': 1.1, 'c2': 1.1, 'c3': 1.1}, {'c1': 1.2, 'c2': 1.2, 'c3': 1.2}] + + >>> price_vector = {'c1': 0.0, 'c2': 0.0, 'c3': 0.0} + >>> demands = {"c1": 2, "c2": -1, "c3": 0} + >>> steps = [0.1, 0.2] + >>> generate_gradient_neighbors(price_vector, demands, steps) + [{'c1': 0.2, 'c2': 0.0, 'c3': 0.0}, {'c1': 0.4, 'c2': 0.0, 'c3': 0.0}] + + >>> price_vector = {'c1': 1.0, 'c2': 1.0, 'c3': 1.0} + >>> demands = {"c1": 2, "c2": -1, "c3": 0} + >>> steps = [0.1, 0.2, 0.3, 0.4, 0.5] + >>> generate_gradient_neighbors(price_vector, demands, steps) + [{'c1': 1.2, 'c2': 0.9, 'c3': 1.0}, {'c1': 1.4, 'c2': 0.8, 'c3': 1.0}, {'c1': 1.6, 'c2': 0.7, 'c3': 1.0}, {'c1': 1.8, 'c2': 0.6, 'c3': 1.0}, {'c1': 2.0, 'c2': 0.5, 'c3': 1.0}] + """ + logger.info("Generating gradient neighbors") + neighbors = [] + for step in steps: + new_price_vector = dict() + for k,p in price_vector.items(): + new_price_vector[k] = max(0.0, p + (step * demands[k])) + + # new_price_vector = {k: price_vector.get(k) + (step * demands.get(k)) for k in price_vector.keys()} + neighbors.append(new_price_vector) + logger.debug("Gradient neighbors: %s", neighbors) + return neighbors + +def find_preferred_schedule_adapter(alloc: AllocationBuilder): + ''' + Helper function to arrange data for the "find_preference_order_for_each_student" function + + :param alloc: Allocation object. + + :return (dict) Dictionary of preferred schedules. + + >>> instance = Instance( + ... agent_conflicts = {"Alice": [], "Bob": [], "Tom": []}, + ... item_conflicts = {"c1": [], "c2": [], "c3": []}, + ... agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 1}, + ... item_capacities = {"c1": 1, "c2": 1, "c3": 1}, + ... valuations = {"Alice": {"c1": 100, "c2": 0, "c3": 0}, + ... "Bob": {"c1": 0, "c2": 100, "c3": 0}, + ... "Tom": {"c1": 0, "c2": 0, "c3": 100} + ... }) + >>> allocation = AllocationBuilder(instance) + >>> find_preferred_schedule_adapter(allocation) + {'Alice': [[1, 1, 0], [1, 0, 1], [1, 0, 0], [0, 1, 1], [0, 1, 0], [0, 0, 1]], 'Bob': [[1, 1, 0], [0, 1, 1], [0, 1, 0], [1, 0, 1], [1, 0, 0], [0, 0, 1]], 'Tom': [[0, 0, 1], [1, 0, 0], [0, 1, 0]]} + ''' + logger.info("Preparing to find preferred schedule") + item_conflicts={item: alloc.instance.item_conflicts(item) for item in alloc.instance.items} + agent_conflicts={agent: alloc.instance.agent_conflicts(agent) for agent in alloc.instance.agents} + return find_preference_order_for_each_student(alloc.instance._valuations , alloc.instance._agent_capacities , item_conflicts , agent_conflicts) + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/fairpyx/algorithms/course_match/main_course_match.py b/fairpyx/algorithms/course_match/main_course_match.py new file mode 100644 index 0000000..2e842f7 --- /dev/null +++ b/fairpyx/algorithms/course_match/main_course_match.py @@ -0,0 +1,78 @@ +""" +Course Match: A Large-Scale Implementation ofApproximate Competitive Equilibrium fromEqual Incomes for Combinatorial Allocation, +by Eric Budish,a Gérard P. Cachon,b Judd B. Kessler,b Abraham Othmanb +June 2, 2016 +https://pubsonline.informs.org/doi/epdf/10.1287/opre.2016.1544 + +Programmer: Naama Shiponi and Ben Dabush +Date: 1/6/2024 +""" +from fairpyx.instances import Instance +from fairpyx.allocations import AllocationBuilder + +from fairpyx.algorithms.course_match import A_CEEI +from fairpyx.algorithms.course_match import remove_oversubscription +from fairpyx.algorithms.course_match import reduce_undersubscription + +def course_match_algorithm(alloc: AllocationBuilder, budget: dict, priorities_student_list: list = [], time : int = 60): + """ + Perform the Course Match algorithm to find the best course allocations. + + :param alloc: (AllocationBuilder) an allocation builder object + + :return: (dict) course allocations + + """ + price_vector = A_CEEI.A_CEEI(alloc,budget,time) + price_vector = remove_oversubscription.remove_oversubscription(alloc, price_vector, budget) + reduce_undersubscription.reduce_undersubscription(alloc, price_vector, budget, priorities_student_list) + + + return alloc + +def check_envy(res, instance : Instance): + alloc = AllocationBuilder(instance) + my_valuations = {agent: sum([alloc.instance._valuations[agent][item] for item in res[agent]]) for agent in res.keys()} + # print(my_valuations) + envy_agent = {agent : {} for agent in res.keys()} + for agent in res.keys(): + for agent2 in res.keys(): + if agent != agent2: + agent_val_sun_for_agent2_baskets = sum([alloc.instance._valuations[agent][item] for item in res[agent2]]) + if agent_val_sun_for_agent2_baskets > my_valuations[agent]: + envy_agent[agent][agent2] = agent_val_sun_for_agent2_baskets - my_valuations[agent] + + for agent in envy_agent.keys(): + for agent2 in envy_agent[agent].keys(): + agent_val_for_agent2_baskets= [alloc.instance._valuations[agent][item] for item in res[agent2]] + sum_val = sum(agent_val_for_agent2_baskets) + boolian_check_envy = False + for val in agent_val_for_agent2_baskets: + if sum_val - val <= my_valuations[agent]: + boolian_check_envy=True + break + if not boolian_check_envy: + print(f"EF?!?! agent1 {agent} envies agent2 {agent2} by {envy_agent[agent][agent2]}") + print("check_envy done") + + +if __name__ == "__main__": + import doctest + doctest.testmod() + + # from fairpyx import divide + # instance = Instance( + # agent_conflicts = {"Alice": [], "Bob": []}, + # item_conflicts = {"c1": [], "c2": [], "c3": []}, + # agent_capacities = {"Alice": 2, "Bob": 1}, + # item_capacities = {"c1": 1, "c2": 2, "c3": 2}, + # valuations = {"Alice": {"c1": 100, "c2": 60, "c3": 0}, + # "Bob": {"c1": 0, "c2": 100, "c3": 0}, + # }) + # budget = {"Alice": 3.0, "Bob": 1.0} + + + # res = divide(course_match_algorithm, instance, budget=budget) + # print(res) + + # check_envy(res,instance) \ No newline at end of file diff --git a/fairpyx/algorithms/course_match/reduce_undersubscription.py b/fairpyx/algorithms/course_match/reduce_undersubscription.py new file mode 100644 index 0000000..128c14f --- /dev/null +++ b/fairpyx/algorithms/course_match/reduce_undersubscription.py @@ -0,0 +1,375 @@ +""" +Course Match: A Large-Scale Implementation ofApproximate Competitive Equilibrium fromEqual Incomes for Combinatorial Allocation +Eric Budish,a Gérard P. Cachon,b Judd B. Kessler,b Abraham Othmanb +June 2, 2016 +https://pubsonline.informs.org/doi/epdf/10.1287/opre.2016.1544 + +Naama Shiponi and Ben Dabush +1/6/2024 +""" + +import logging +logger = logging.getLogger(__name__) +from fairpyx.algorithms.course_match.A_CEEI import ( + compute_surplus_demand_for_each_course, + find_best_schedule, + find_preference_order_for_each_student, +) +from fairpyx.instances import Instance +from fairpyx.allocations import AllocationBuilder + +""" +Algorithm 3: The algorithm is designed to refill all the courses that, following Algorithm 2, have space in them. +""" + + +def reduce_undersubscription(allocation: AllocationBuilder, price_vector: dict, student_budgets: dict, priorities_student_list: list) -> AllocationBuilder: + """ + Perform automated aftermarket allocations with increased budget and restricted allocations. + + :param allocation: (AllocationBuilder) current course allocations + :param price_vector: (dict) price vector for courses + :param student_list: List of students ordered by their class year descending and budget surplus ascending + :param student_budgets: Budget for each student + + :return: Updated course allocations + """ + logger.info("Starting reduce undersubscription algorithm (algorithm 3).") + item_conflicts, agent_conflicts = calculate_conflicts(allocation) + preferred_schedule = find_preference_order_for_each_student(allocation.instance._valuations, allocation.instance._agent_capacities, item_conflicts, agent_conflicts) + logger.debug('Preferred schedule calculated: %s', preferred_schedule) + + # Calculate the demand for each course based on the price vector and student budgets + course_demands_dict = compute_surplus_demand_for_each_course(price_vector, allocation, student_budgets, preferred_schedule) + logger.info('Course demands calculated: %s', course_demands_dict) + + # Identify undersubscribed courses (courses with negative demand) + capacity_undersubscribed_courses = {course: -1 * course_demand for course, course_demand in course_demands_dict.items() if course_demand < 0} + logger.info('Undersubscribed courses identified: %s', capacity_undersubscribed_courses) + + student_schedule = find_best_schedule(price_vector, student_budgets, preferred_schedule) + student_schedule_dict = create_dictionary_of_schedules(student_schedule, allocation.instance.items, allocation.instance.agents) + logger.debug('Initial student schedules: %s', student_schedule_dict) + + student_list = calculate_remaining_budgets(price_vector, student_budgets, student_schedule_dict, priorities_student_list, allocation) + logger.debug('Student list with remaining budgets: %s', student_list) + + # Reoptimize student schedules to fill undersubscribed courses + student_schedule_dict = reoptimize_student_schedules(allocation, price_vector, student_list, student_budgets, student_schedule_dict, capacity_undersubscribed_courses) + + # Update the allocation with the new student schedules + for student, schedule in student_schedule_dict.items(): + allocation.give_bundle(student, schedule) + logger.info('Updated allocation for student %s: %s', student, schedule) + + logger.info("Finished reduce_undersubscription algorithm.") + return allocation + + +def calculate_conflicts(allocation: AllocationBuilder) -> tuple: + """ + Calculate conflicts for items and agents. + + :param allocation: (AllocationBuilder) + + :return: Tuple containing item conflicts and agent conflicts + + >>> instance = Instance( + ... agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities={"c1": 2, "c2": 2, "c3": 2}, + ... valuations={ + ... "Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}, + ... }, + ... ) + >>> allocation = AllocationBuilder(instance) + >>> calculate_conflicts(allocation) + ({'c1': set(), 'c2': set(), 'c3': set()}, {'Alice': set(), 'Bob': set(), 'Tom': set()}) + """ + item_conflicts = { + item: allocation.instance.item_conflicts(item) + for item in allocation.instance.items + } + agent_conflicts = { + agent: allocation.instance.agent_conflicts(agent) + for agent in allocation.instance.agents + } + + logger.debug('Calculated item conflicts: %s', item_conflicts) + logger.debug('Calculated agent conflicts: %s', agent_conflicts) + return item_conflicts, agent_conflicts + + +def create_dictionary_of_schedules(student_schedule, course, students) -> dict: + """ + Create a dictionary of student schedules. + + :param student_schedule: (list of list) schedule of students + :param course: (list) list of courses + :param students: (list) list of students + + :return: (dict) dictionary of student schedules + + >>> student_schedule = [[1, 0, 1], [1, 1, 0], [1, 0, 1]] + >>> course = ["c1", "c2", "c3"] + >>> students = ["Alice", "Bob", "Tom"] + >>> create_dictionary_of_schedules(student_schedule, course, students) + {'Alice': ['c1', 'c3'], 'Bob': ['c1', 'c2'], 'Tom': ['c1', 'c3']} + """ + schedule_dict = {student: [course for j, course in enumerate(course) if student_schedule[i][j] == 1] for i, student in enumerate(students)} + logger.debug('Created dictionary of schedules: %s', schedule_dict) + return schedule_dict + + +def calculate_remaining_budgets(price_vector: dict, student_budgets: dict, student_courses: dict, priorities_student_list: list, alloc: AllocationBuilder) -> list: + """ + Calculate remaining budget for each student and sort based on priority and remaining budget. + + :param price_vector: (dict) price vector for courses + :param student_budgets: (dict) budget for each student + :param student_courses: (dict) courses allocated to each student + :param priorities_student_list: (list of lists) Each list represents students with higher priority than the group after it + + :return: List of tuples containing student and their remaining budget + + >>> instance = Instance( + ... agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities={"c1": 2, "c2": 2, "c3": 2}, + ... valuations={ + ... "Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}, + ... }, + ... ) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {"c1": 1.2, "c2": 0.7, "c3": 1.3} + >>> student_budgets = {"Alice": 2.2, "Bob": 1.4, "Tom": 2.6} + >>> student_courses = {'Alice': ['c1', 'c2'], 'Bob': ['c1'], 'Tom': ['c1', 'c3']} + >>> priorities_student_list = [["Alice"], ["Bob", "Tom"]] + >>> calculate_remaining_budgets(price_vector, student_budgets, student_courses, priorities_student_list, allocation) + [('Alice', 0.30000000000000027), ('Tom', 0.10000000000000009), ('Bob', 0.19999999999999996)] + """ + remaining_budgets = [] + for student, courses in student_courses.items(): + total_cost = sum(price_vector[course] for course in courses) + remaining_budget = student_budgets[student] - total_cost + remaining_budgets.append((student, remaining_budget)) + logger.debug('Student %s, Courses: %s, Total Cost: %f, Remaining Budget: %f', student, courses, total_cost, remaining_budget) + + # Create a priority dictionary to map each student to their priority group index + if len(priorities_student_list) == 0: + priorities_student_list = [[agent for agent in alloc.remaining_agents()]] + priority_dict = {} + for priority_index, group in enumerate(priorities_student_list): + for student in group: + priority_dict[student] = priority_index + + # Sort first by priority and then by remaining budget within each priority group + remaining_budgets.sort(key=lambda x: (priority_dict[x[0]], x[1])) + + logger.debug('Calculated remaining budgets: %s', remaining_budgets) + return remaining_budgets + + +def reoptimize_student_schedules(allocation, price_vector, student_list, student_budgets, student_schedule_dict, capacity_undersubscribed_courses) -> dict: + """ + Reoptimize student schedules to fill undersubscribed courses. + + :param allocation: (AllocationBuilder) + :param price_vector: (dict) price vector for courses + :param student_list: (list) list of students with their remaining budgets + :param student_budgets: (dict) budget for each student + :param student_schedule_dict: (dict) current schedules of students + :param capacity_undersubscribed_courses: (dict) courses that are undersubscribed + + :return: Updated student schedules + """ + logger.info("Starting reoptimization of student schedules.") + not_done = True + while not_done and len(capacity_undersubscribed_courses) != 0: + not_done = False + for student in student_list: + current_bundle = list(student_schedule_dict[student[0]]) + current_bundle.extend(x for x in list(capacity_undersubscribed_courses.keys()) if x not in current_bundle) + current_bundle.sort() + student_budget = {student[0]: 1.1 * student_budgets[student[0]]} + new_bundle = allocation_function(allocation, student[0], current_bundle, price_vector, student_budget) + if is_new_bundle_better(allocation, student[0], student_schedule_dict[student[0]], new_bundle.get(student[0], {})): + not_done = True + update_student_schedule_dict(student, student_schedule_dict, new_bundle, capacity_undersubscribed_courses) + logger.debug('Updated student %s schedule: %s', student[0], student_schedule_dict[student[0]]) + break # Only one student changes their allocation in each pass + logger.info("Finished reoptimization of student schedules.") + return student_schedule_dict + + +def update_student_schedule_dict(student, student_schedule_dict, new_bundle, capacity_undersubscribed_courses) -> None: + """ + Update student schedule dictionary and capacity of undersubscribed courses. + + :param student: (tuple) student and their remaining budget + :param student_schedule_dict: (dict) current schedules of students + :param new_bundle: (dict) new schedules of students + :param capacity_undersubscribed_courses: (dict) courses that are undersubscribed + """ + diff_in_bundle = list(set(new_bundle.get(student[0])).symmetric_difference(set(student_schedule_dict[student[0]]))) + for course in diff_in_bundle: + if course in student_schedule_dict[student[0]]: + capacity_undersubscribed_courses[course] = capacity_undersubscribed_courses.get(course, 0) + 1 + else: + capacity_undersubscribed_courses[course] -= 1 + if capacity_undersubscribed_courses[course] == 0: + capacity_undersubscribed_courses.pop(course) + student_schedule_dict.update({student[0]: new_bundle.get(student[0])}) + logger.debug('Updated undersubscribed course capacities: %s', capacity_undersubscribed_courses) + +def allocation_function(allocation: AllocationBuilder, student: str, student_allocation: dict, price_vector: dict, student_budget: dict) -> dict: + """ + Function to reoptimize student's schedule. + + :param allocation: (AllocationBuilder) current course allocations + :param student: (str) name of student + :param student_allocation: (dict) Schedule of student to reoptimize + :param price_vector: (dict) price vector for courses + :param student_budget: (dict) New student's budget + + :return: (dict) new course allocations + """ + limited_student_valuations = filter_valuations_for_courses(allocation, student, student_allocation) + item_conflicts, agent_conflicts = calculate_conflicts(allocation) + agent_capacities = {student: allocation.instance._agent_capacities[student]} + preferred_schedule = find_preference_order_for_each_student(limited_student_valuations, agent_capacities, item_conflicts, agent_conflicts) + limited_price_vector = {course: price for course, price in price_vector.items() if course in student_allocation} + new_allocation = find_best_schedule(limited_price_vector, student_budget, preferred_schedule) + new_allocation_dict = create_dictionary_of_schedules(new_allocation, student_allocation, agent_capacities.keys()) + logger.debug('Reoptimized schedule for student %s: %s', student, new_allocation_dict) + return new_allocation_dict + +def filter_valuations_for_courses(allocation, student, student_allocation) -> dict: + """ + Filter valuations for the courses in the student's allocation. + + :param allocation: (AllocationBuilder) + :param student: (str) name of student + :param student_allocation: (dict) Schedule of student to reoptimize + + :return: (dict) filtered valuations for the courses in the student's allocation + + >>> instance = Instance( + ... agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities={"c1": 2, "c2": 2, "c3": 2}, + ... valuations={ + ... "Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}, + ... }, + ... ) + >>> allocation = AllocationBuilder(instance) + >>> student_allocation = {"c1", "c3"} + >>> filter_valuations_for_courses(allocation, "Alice", student_allocation) + {'Alice': {'c1': 50, 'c3': 80}} + """ + filtered_valuations = { + student: { + course: valuations + for course, valuations in allocation.instance._valuations.get( + student, {} + ).items() + if course in student_allocation + } + } + logger.debug('Filtered valuations for student %s: %s', student, filtered_valuations) + return filtered_valuations + +def is_new_bundle_better(allocation: AllocationBuilder, student: str, current_bundle: set, new_bundle: set) -> bool: + """ + Check if the current bundle and new bundle are equal. + + :param allocation: (AllocationBuilder) + :param student: (str) name of student + :param current_bundle: (set) current course bundle + :param new_bundle: (set) new course bundle + + :return: (bool) True if bundles are equal, False otherwise + >>> instance = Instance( + ... agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities={"c1": 2, "c2": 2, "c3": 2}, + ... valuations={ + ... "Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}, + ... }, + ... ) + >>> allocation = AllocationBuilder(instance) + >>> is_new_bundle_better(allocation, "Alice", ["c1", "c3"], ["c1", "c3"]) + False + >>> is_new_bundle_better(allocation, "Alice", ["c1", "c3"], ["c2", "c3"]) + False + >>> is_new_bundle_better(allocation, "Alice", ["c1"], ["c2", "c3"]) + True + >>> is_new_bundle_better(allocation, "Alice", ["c3"], ["c1", "c2"]) + False + """ + sum_valuations_cur = sum(valuations for course, valuations in allocation.instance._valuations.get(student, {}).items() if course in current_bundle) + sum_valuations_new = sum(valuations for course, valuations in allocation.instance._valuations.get(student, {}).items() if course in new_bundle) + + logger.debug('Current bundle valuations for student %s: %g', student, sum_valuations_cur) + logger.debug('New bundle valuations for student %s: %g', student, sum_valuations_new) + + if (sum_valuations_cur < sum_valuations_new) or (len(current_bundle) < len(new_bundle) and sum_valuations_cur <= sum_valuations_new): + logger.info('New bundle is better for student %s.', student) + return True + logger.info('New bundle is not better for student %s.', student) + return False + + + + + + +if __name__ == "__main__": + # pass + import doctest + print(doctest.testmod()) + +# instance = Instance( +# agent_conflicts = {"Alice": [], "Bob": []}, +# item_conflicts = {"c1": [], "c2": [], "c3": []}, +# agent_capacities = {"Alice": 2, "Bob": 1}, +# item_capacities = {"c1": 1, "c2": 2, "c3": 2}, +# valuations = {"Alice": {"c1": 100, "c2": 60, "c3": 0}, +# "Bob": {"c1": 0, "c2": 100, "c3": 0}, +# }) +# allocation = AllocationBuilder(instance) +# student_budgets = {"Alice": 3.0, "Bob": 1.0} +# price_vector = {"c1": 2.0, "c2": 1.0, "c3": 5.0} +# print( +# reduce_undersubscription( +# allocation, +# price_vector, +# student_budgets, +# ).bundles + # ) + + # {'Alice': ['c1', 'c2'], 'Bob': ['c2']} + + # instance = Instance( + # agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + # item_capacities={"c1": 2, "c2": 2, "c3": 2}, + # valuations={ + # "Alice": {"c1": 50, "c2": 20, "c3": 80}, + # "Bob": {"c1": 60, "c2": 40, "c3": 30}, + # "Tom": {"c1": 70, "c2": 30, "c3": 70}, + # }, + # ) + # allocation = AllocationBuilder(instance) + # price_vector = {"c1": 1.26875, "c2": 0.9, "c3": 1.24375} + # student_budgets = {"Alice": 2.2, "Bob": 2.1, "Tom": 2.0} + # print( + # reduce_undersubscription( + # allocation, + # price_vector, + # student_budgets, + # ).bundles + # ) \ No newline at end of file diff --git a/fairpyx/algorithms/course_match/remove_oversubscription.py b/fairpyx/algorithms/course_match/remove_oversubscription.py new file mode 100644 index 0000000..f41545b --- /dev/null +++ b/fairpyx/algorithms/course_match/remove_oversubscription.py @@ -0,0 +1,197 @@ +""" +Course Match: A Large-Scale Implementation ofApproximate Competitive Equilibrium fromEqual Incomes for Combinatorial Allocation +Eric Budish,a Gérard P. Cachon,b Judd B. Kessler,b Abraham Othmanb +June 2, 2016 +https://pubsonline.informs.org/doi/epdf/10.1287/opre.2016.1544 + +Naama Shiponi and Ben Dabush +1/6/2024 +""" +import logging +logger = logging.getLogger(__name__) +from fairpyx.instances import Instance +from fairpyx.allocations import AllocationBuilder +from fairpyx.algorithms.course_match.A_CEEI import ( + compute_surplus_demand_for_each_course, + find_best_schedule, + find_preference_order_for_each_student, +) + +""" +Algorithm 2 : The algorithm makes sure that there are no courses that have more students registered than their capacity. +""" + + +def remove_oversubscription( + allocation: AllocationBuilder, + price_vector: dict, + student_budgets: dict, + epsilon: float = 0.1, + compute_surplus_demand_for_each_course: callable = compute_surplus_demand_for_each_course, +): + """ + Perform oversubscription elimination to adjust course prices. + + :param allocation: AllocationBuilder + :param price_vector: Initial price vector (dict of floats) + :param student_budgets: dict of student budgets (dict of floats) + :param epsilon: Small value to determine when to stop binary search + :param demand_function: Function that takes price vector and returns excess demand vector + + :return: Adjusted price vector (dict of floats) + + :pseudo code + Input: p* heuristic search solution price vector from Algorithm 1, + ¯p scalar price greater than any budget, + ε smallerthan budget differences, + excess demand function d(p) that maps a price vector to the demand of a coursebeyond its maximum capacity. + Output: Altered p* without oversubscription + + 1: j' ← argMax_j (d_j (p*)) # j' is the most oversubscribed course + 2: while d_j'(p*) > 0 do + 3: d* ← d_j'(p*)/2 # Perform binary search on the price of course j' until oversubscription equals (at most) d* + 4: pl ← p*_j' + 5: ph ← ¯p + 6: repeat # Our target price is always in the interval [pl ,ph], which we progressively shrink inhalf in each iteration of this loop + 7: p*_j' ← (pl + ph )/2 + 8: if d_j'(p*) > d* then + 9: pl ← p*_j' + 10: else + 11: ph ← p*_j' + 12: end if + 13: until ph - pl < ε + 14: p*_j' ← ph # Set to the higher price to be sure oversubscription is at most d* + 15: j' ← argMax_j d_j(p*) # Find the most oversubscribed course with the new prices + 16: end while + + :example + >>> instance = Instance( + ... agent_capacities = {"Alice": 2, "Bob": 2, "Tom": 2}, + ... item_capacities = {"c1": 1, "c2": 1, "c3": 1}, + ... valuations = {"Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}} + ... ) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {"c1": 1.2, "c2": 0.9, "c3": 1} + >>> epsilon = 0.1 + >>> student_budgets = {"Alice": 2.2, "Bob": 2.1, "Tom": 2.0} + >>> remove_oversubscription(allocation, price_vector, student_budgets, epsilon, compute_surplus_demand_for_each_course) + {'c1': 2.0421875000000003, 'c2': 1.1515624999999998, 'c3': 2.0562500000000004} + + >>> instance = Instance( + ... agent_capacities = {"Alice": 3, "Bob": 3, "Tom": 3}, + ... item_capacities = {"c1": 3, "c2": 3, "c3": 3}, + ... valuations = {"Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}} + ... ) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {"c1": 1.2, "c2": 0.9, "c3": 1} + >>> epsilon = 0.1 + >>> student_budgets = {"Alice": 2.2, "Bob": 2.1, "Tom": 2.0} + >>> remove_oversubscription(allocation, price_vector, student_budgets, epsilon, compute_surplus_demand_for_each_course) + {'c1': 1.2, 'c2': 0.9, 'c3': 1} + + >>> instance = Instance( + ... agent_capacities = {"Alice": 3, "Bob": 3, "Tom": 3}, + ... item_capacities = {"c1": 1, "c2": 1, "c3": 1}, + ... valuations = {"Alice": {"c1": 50, "c2": 20, "c3": 80}, + ... "Bob": {"c1": 60, "c2": 40, "c3": 30}, + ... "Tom": {"c1": 70, "c2": 30, "c3": 70}} + ... ) + >>> allocation = AllocationBuilder(instance) + >>> price_vector = {"c1": 0, "c2": 0, "c3": 0} + >>> epsilon = 0.1 + >>> student_budgets = {"Alice": 2.2, "Bob": 2.1, "Tom": 2.0} + >>> remove_oversubscription(allocation, price_vector, student_budgets, epsilon, compute_surplus_demand_for_each_course) + {'c1': 2.0125, 'c2': 0.21113281250000004, 'c3': 2.0125} + """ + logger.info("Starting remove oversubscription algorithm") + max_budget = max(student_budgets.values()) + epsilon + logger.debug('Max budget set to %g', max_budget) + item_conflicts = { + item: allocation.instance.item_conflicts(item) + for item in allocation.instance.items + } + agent_conflicts = { + agent: allocation.instance.agent_conflicts(agent) + for agent in allocation.instance.agents + } + + preferred_schedule = find_preference_order_for_each_student( + allocation.instance._valuations, + allocation.instance._agent_capacities, + item_conflicts, + agent_conflicts, + ) + logger.info("Preferred schedule determined") + while True: + excess_demands = compute_surplus_demand_for_each_course(price_vector, allocation, student_budgets, preferred_schedule) + highest_demand_course = max(excess_demands, key=excess_demands.get) + highest_demand = excess_demands[highest_demand_course] + logger.debug('Highest demand course: %s with demand %g', highest_demand_course, highest_demand) + if highest_demand <= 0: + logger.info("No oversubscription detected, ending remove oversubscription algorithm") + break + + d_star = highest_demand / 2 + low_price = price_vector[highest_demand_course] + high_price = max_budget + + logger.info('Starting binary search for course %s', highest_demand_course) + while high_price - low_price >= epsilon: + p_mid = (low_price + high_price) / 2 + price_vector[highest_demand_course] = p_mid + current_demand = compute_surplus_demand_for_each_course(price_vector, allocation, student_budgets, preferred_schedule)[highest_demand_course] + logger.debug('Mid price set to %g, current demand %g', p_mid, current_demand) + if current_demand > d_star: + low_price = p_mid + logger.debug('Current demand %g is greater than d_star %g, updating low_price to %g', current_demand, d_star, low_price) + else: + high_price = p_mid + logger.debug('Current demand %g is less than or equal to d_star %g, updating high_price to %g', current_demand, d_star, high_price) + + price_vector[highest_demand_course] = high_price + logger.info('Final price for course %s set to %g', highest_demand_course, high_price) + + logger.info("Oversubscription removal completed") + return price_vector + +if __name__ == "__main__": + import doctest + print(doctest.testmod()) + + instance = Instance( + agent_capacities={"Alice": 2, "Bob": 2, "Tom": 2}, + item_capacities={"c1": 1, "c2": 1, "c3": 1}, + valuations={ + "Alice": {"c1": 50, "c2": 20, "c3": 80}, + "Bob": {"c1": 60, "c2": 40, "c3": 30}, + "Tom": {"c1": 70, "c2": 30, "c3": 70}, + }, + ) + allocation = AllocationBuilder(instance) + price_vector = {"c1": 1.2, "c2": 0.9, "c3": 1} + epsilon = 0.1 + student_budgets = {"Alice": 2.2, "Bob": 2.1, "Tom": 2.0} + + max_budget = max(student_budgets.values()) + 0.01 # ¯p scalar price greater than any budget + # preferred_schedule = find_preferred_schedule(allocation.instance._valuations, allocation.instance._agent_capacities, allocation.instance.item_conflicts, allocation.instance.agent_conflicts) + item_conflicts = { + item: allocation.instance.item_conflicts(item) + for item in allocation.instance.items + } + agent_conflicts = { + agent: allocation.instance.agent_conflicts(agent) + for agent in allocation.instance.agents + } + + preferred_schedule = find_preference_order_for_each_student( + allocation.instance._valuations, + allocation.instance._agent_capacities, + item_conflicts, + agent_conflicts, + ) + # print(preferred_schedule) + # remove_oversubscription(allocation, price_vector, student_budgets, epsilon, compute_surplus_demand_for_each_course)