Skip to content

Commit

Permalink
Merge pull request #9 from zachibs/main
Browse files Browse the repository at this point in the history
Implementation of Algorithm from 'COURSE BIDDING AT BUSINESS SCHOOLS'
  • Loading branch information
erelsgl authored Sep 19, 2024
2 parents cd86f85 + 35485fa commit 813c1e2
Show file tree
Hide file tree
Showing 63 changed files with 8,513 additions and 30 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
Expand Down
16 changes: 9 additions & 7 deletions experiments/compare_course_allocation_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
TIME_LIMIT = 100

algorithms_to_check = [
crs.gale_shapley,
crs.utilitarian_matching,
crs.iterated_maximum_matching_unadjusted,
crs.iterated_maximum_matching_adjusted,
Expand Down Expand Up @@ -53,16 +54,16 @@ def course_allocation_with_random_instance_uniform(
value_noise_ratio:float,
algorithm:Callable,
random_seed: int,):
agent_capacity_bounds = [6,6]
item_capacity_bounds = [40,40]
agent_capacity_bounds = (6,6)
item_capacity_bounds = (40,40)
np.random.seed(random_seed)
instance = Instance.random_uniform(
num_of_agents=num_of_agents, num_of_items=num_of_items,
normalized_sum_of_values=normalized_sum_of_values,
agent_capacity_bounds=agent_capacity_bounds,
item_capacity_bounds=item_capacity_bounds,
item_base_value_bounds=[1,max_value],
item_subjective_ratio_bounds=[1-value_noise_ratio, 1+value_noise_ratio]
agent_capacity_bounds=agent_capacity_bounds,
item_capacity_bounds=item_capacity_bounds,
item_base_value_bounds=(1,max_value),
item_subjective_ratio_bounds=(1-value_noise_ratio, 1+value_noise_ratio)
)
return evaluate_algorithm_on_instance(algorithm, instance)

Expand Down Expand Up @@ -127,6 +128,7 @@ def run_szws_experiment():

import json
filename = "data/ariel_5783_input.json"
# filename = "experiments/data/ariel_5783_input.json"
with open(filename, "r", encoding="utf-8") as file:
ariel_5783_input = json.load(file)

Expand Down Expand Up @@ -165,7 +167,7 @@ def run_ariel_experiment():
if __name__ == "__main__":
import logging, experiments_csv
experiments_csv.logger.setLevel(logging.INFO)
run_uniform_experiment()
# run_uniform_experiment()
# run_szws_experiment()
# run_ariel_experiment()

2 changes: 1 addition & 1 deletion experiments/plot_simulation_results.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from experiments_csv import single_plot_results, multi_plot_results
from experiments_csv.plot_results import single_plot_results, multi_plot_results
from matplotlib import pyplot as plt
from pathlib import Path
import sys
Expand Down
216 changes: 202 additions & 14 deletions experiments/results/course_allocation_szws.csv

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 81 additions & 6 deletions experiments/results/course_allocation_uniform.csv

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions fairpyx/algorithms/Gale_Shapley_pareto_dominant_market_mechanism.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
"""
"Course bidding at business schools", by Tayfun Sönmez and M. Utku Ünver (2010)
https://doi.org/10.1111/j.1468-2354.2009.00572.x
Allocate course seats using Gale-Shapley pareto-dominant market mechanism.
Programmer: Zachi Ben Shitrit
Since: 2024-05
"""

from fairpyx import AllocationBuilder
import numpy as np
from typing import Dict, List, Union

import logging
logger = logging.getLogger(__name__)


def gale_shapley(alloc: AllocationBuilder, course_order_per_student: Union[Dict[str, List[str]], None] = None, tie_braking_lottery: Union[None, Dict[str, float]] = None):
"""
Allocate the given items to the given agents using the Gale-Shapley protocol.
Parameters:
alloc (AllocationBuilder): An allocation builder which tracks agent capacities, item capacities, and valuations.
course_order_per_student (Dict[str, List[str]]): A dictionary that matches each agent to their course rankings indicating preferences.
tie_braking_lottery (Dict[str, float]): A dictionary that matches each agent to their tie-breaking additive points (sampled from a uniform distribution [0,1]).
Returns:
Dict[str, List[str]]: A dictionary representing the final allocation of courses to students.
Naive Example:
>>> from fairpyx import Instance, AllocationBuilder
>>> from fairpyx.adaptors import divide
>>> s1 = {"c1": 40, "c2": 60}
>>> s2 = {"c1": 70, "c2": 30}
>>> s3 = {"c1": 70, "c2": 30}
>>> s4 = {"c1": 40, "c2": 60}
>>> s5 = {"c1": 50, "c2": 50}
>>> agent_capacities = {"Alice": 1, "Bob": 1, "Chana": 1, "Dana": 1, "Dor": 1}
>>> course_capacities = {"c1": 3, "c2": 2}
>>> valuations = {"Alice": s1, "Bob": s2, "Chana": s3, "Dana": s4, "Dor": s5}
>>> course_order_per_student = {"Alice": ["c2", "c1"], "Bob": ["c1", "c2"], "Chana": ["c1", "c2"], "Dana": ["c2", "c1"], "Dor": ["c1", "c2"]}
>>> tie_braking_lottery = {"Alice": 0.9, "Bob": 0.1, "Chana": 0.2, "Dana": 0.6, "Dor": 0.4}
>>> instance = Instance(agent_capacities=agent_capacities, item_capacities=course_capacities, valuations=valuations)
>>> divide(gale_shapley, instance=instance, course_order_per_student=course_order_per_student, tie_braking_lottery=tie_braking_lottery)
{'Alice': ['c2'], 'Bob': ['c1'], 'Chana': ['c1'], 'Dana': ['c2'], 'Dor': ['c1']}
Example where the students course order does not align with the bids:
>>> s1 = {"c1": 20, "c2": 15, "c3": 35, "c4": 10, "c5": 20}
>>> s2 = {"c1": 30, "c2": 15, "c3": 20, "c4": 20, "c5": 15}
>>> s3 = {"c1": 40, "c2": 10, "c3": 25, "c4": 10, "c5": 15}
>>> s4 = {"c1": 10, "c2": 10, "c3": 15, "c4": 30, "c5": 35}
>>> s5 = {"c1": 25, "c2": 20, "c3": 30, "c4": 10, "c5": 15}
>>> agent_capacities = {"Alice": 3, "Bob": 3, "Chana": 3, "Dana": 3, "Dor": 3}
>>> course_capacities = {"c1": 4, "c2": 4, "c3": 2, "c4": 3, "c5": 2}
>>> valuations = {"Alice": s1, "Bob": s2, "Chana": s3, "Dana": s4, "Dor": s5}
>>> course_order_per_student = {"Alice": ["c5", "c3", "c1", "c2", "c4"], "Bob": ["c1", "c4", "c5", "c2", "c3"], "Chana": ["c5", "c1", "c4", "c3", "c2"], "Dana": ["c3", "c4", "c1", "c5", "c2"], "Dor": ["c5", "c1", "c4", "c3", "c2"]}
>>> tie_braking_lottery = {"Alice": 0.6, "Bob": 0.4, "Chana": 0.3, "Dana": 0.8, "Dor": 0.2}
>>> instance = Instance(agent_capacities=agent_capacities, item_capacities=course_capacities, valuations=valuations)
>>> divide(gale_shapley, instance=instance, course_order_per_student=course_order_per_student, tie_braking_lottery=tie_braking_lottery)
{'Alice': ['c1', 'c3', 'c5'], 'Bob': ['c1', 'c2', 'c4'], 'Chana': ['c1', 'c2', 'c4'], 'Dana': ['c2', 'c4', 'c5'], 'Dor': ['c1', 'c2', 'c3']}
"""

# Check if inputs are dictionaries
input_to_check_types = [alloc.remaining_agent_capacities, alloc.remaining_item_capacities]
for input_to_check in input_to_check_types:
if(type(input_to_check) != dict):
raise TypeError(f"In the input {input_to_check}, Expected a dict, but got {type(input_to_check).__name__}")
if tie_braking_lottery and type(tie_braking_lottery) != dict:
raise TypeError(f"In the input tie_braking_lottery, Expected a dict or None, but got {type(tie_braking_lottery).__name__}")
if not tie_braking_lottery:
tie_braking_lottery = {student : np.random.uniform(low=0, high=1) for student in alloc.remaining_agents()}

if not course_order_per_student:
course_order_per_student = {student : generate_naive_course_order_for_student(student, alloc) for student in alloc.remaining_agents()}
logger.info(f"Created course_order_per_student: {course_order_per_student}")

was_an_offer_declined: bool = True
course_to_on_hold_students: Dict[str, Dict[str, float]] = {course: {} for course in alloc.remaining_items()}
student_to_rejection_count: Dict[str, int] = {student: alloc.remaining_agent_capacities[student] for student in alloc.remaining_agents()}

logger.info(f"We have {len(alloc.remaining_agents())} agents")
logger.info(f"The students allocation capacities are: {alloc.remaining_agent_capacities}")
logger.info(f"The courses capacities are: {alloc.remaining_item_capacities}")
logger.info(f"The tie-braking lottery results are: {tie_braking_lottery}")
for agent in alloc.remaining_agents():
agent_bids = {course: alloc.effective_value(agent, course) for course in alloc.remaining_items()}
logger.info(f"Student '{agent}' bids are: {agent_bids}")

step = 0
while(was_an_offer_declined):
step += 1
logger.info(f"\n *** Starting round #{step} ***")
was_an_offer_declined = False
logger.info("Each student who is rejected from k > 0 courses in the previous step proposes to his best remaining k courses based on his stated preferences")
for student in alloc.remaining_agents():
student_capability: int = student_to_rejection_count[student]
for index in range(student_capability):
if(not course_order_per_student[student]):
logger.info(f"Student {student} already proposed to all his desired courses")
continue
wanted_course = course_order_per_student[student].pop(0)
if(wanted_course in course_to_on_hold_students):
if(student in course_to_on_hold_students[wanted_course]):
continue
try:
student_to_course_proposal = alloc.effective_value(student, wanted_course)
course_to_on_hold_students[wanted_course][student] = student_to_course_proposal
logger.info(f"Student '{student} proposes to course {wanted_course} with a bid of {student_to_course_proposal}")
except Exception as e:
return {}

logger.info("Each course c considers the new proposals together with the proposals on hold and rejects all but the highest bidding Qc (the maximum capacity of students in course c) students")
student_to_rejection_count = {student: 0 for student in alloc.remaining_agents()}
for course_name in course_to_on_hold_students:
course_capacity = alloc.remaining_item_capacities[course_name]
if(type(course_capacity) == np.float64):
course_capacity = int(course_capacity)
course_to_offerings = course_to_on_hold_students[course_name]
logger.info(f"Course {course_name} considers the next offerings: {course_to_offerings}")
if len(course_to_offerings) == 0:
continue
elif len(course_to_offerings) <= course_capacity:
continue
logger.info("In case there is a tie, the tie-breaking lottery is used to determine who is rejected and who will be kept on hold.")
on_hold_students_sorted_and_tie_breaked = sort_and_tie_break(course_to_offerings, tie_braking_lottery)
course_to_on_hold_students[course_name].clear()
for key, value in on_hold_students_sorted_and_tie_breaked[:course_capacity]:
course_to_on_hold_students[course_name][key] = value

rejected_students = on_hold_students_sorted_and_tie_breaked[course_capacity:]
for rejected_student, bid in rejected_students:
logger.info(f"Agent '{rejected_student}' was rejected from course {course_name}")
student_to_rejection_count[rejected_student] += 1
was_an_offer_declined = True

logger.info("The procedure terminates when no proposal is rejected, and at this stage course assignments are finalized.")
final_course_matchings = course_to_on_hold_students.items()
for course_name, matching in final_course_matchings:
for student, bid in matching.items():
alloc.give(student, course_name, logger)
logger.info(f"The final course matchings are: {alloc.bundles}")


def sort_and_tie_break(input_dict: Dict[str, float], tie_braking_lottery: Dict[str, float]) -> List[tuple[str, float]]:
"""
Sorts a dictionary by its values in descending order and adds a number
to the values of keys with the same value to break ties.
Parameters:
input_dict (Dict[str, float]): A dictionary with string keys and float values representing student bids.
tie_braking_lottery (Dict[str, float]): A dictionary with string keys and float values for tie-breaking.
Returns:
List[tuple[str, float]]: A list of tuples containing student names and their modified bids, sorted in descending order.
Examples:
>>> input_dict = {"Alice": 45, "Bob": 55, "Chana": 45, "Dana": 60}
>>> tie_braking_lottery = {"Alice": 0.3, "Bob": 0.2, "Chana": 0.4, "Dana": 0.1}
>>> sort_and_tie_break(input_dict, tie_braking_lottery)
[('Dana', 60), ('Bob', 55), ('Chana', 45), ('Alice', 45)]
"""


# Sort the dictionary by adjusted values in descending order
sorted_dict = (sorted(input_dict.items(), key=lambda item: item[1] + tie_braking_lottery[item[0]], reverse=True))

return sorted_dict


def generate_naive_course_order_for_student(student: str, alloc: AllocationBuilder) -> List[str]:
"""
Generate a naive course order for a given student based on the effective value the student assigns to each course.
Parameters:
student (str): The student's name.
alloc (AllocationBuilder): An allocation builder which tracks agent capacities, item capacities, and valuations.
Returns:
List[str]: A list of course names sorted by the effective value the student assigns to each course, in descending order.
Example:
>>> from fairpyx import Instance, AllocationBuilder
>>> s1 = {"c1": 40, "c2": 60}
>>> s2 = {"c1": 70, "c2": 30}
>>> s3 = {"c1": 70, "c2": 30}
>>> s4 = {"c1": 40, "c2": 60}
>>> s5 = {"c1": 50, "c2": 50}
>>> agent_capacities = {"Alice": 1, "Bob": 1, "Chana": 1, "Dana": 1, "Dor": 1}
>>> course_capacities = {"c1": 3, "c2": 2}
>>> valuations = {"Alice": s1, "Bob": s2, "Chana": s3, "Dana": s4, "Dor": s5}
>>> course_order_per_student = {"Alice": ["c2", "c1"], "Bob": ["c1", "c2"], "Chana": ["c1", "c2"], "Dana": ["c2", "c1"], "Dor": ["c1", "c2"]}
>>> tie_braking_lottery = {"Alice": 0.9, "Bob": 0.1, "Chana": 0.2, "Dana": 0.6, "Dor": 0.4}
>>> instance = Instance(agent_capacities=agent_capacities, item_capacities=course_capacities, valuations=valuations)
>>> alloc = AllocationBuilder(instance)
>>> generate_naive_course_order_for_student("Alice", alloc)
['c2', 'c1']
>>> generate_naive_course_order_for_student("Bob", alloc)
['c1', 'c2']
>>> generate_naive_course_order_for_student('Chana', alloc)
['c1', 'c2']
>>> generate_naive_course_order_for_student('Dana', alloc)
['c2', 'c1']
>>> generate_naive_course_order_for_student('Dor', alloc)
['c1', 'c2']
"""
# Get all courses
courses: List[str] = alloc.remaining_items()

# Calculate the effective value of each course for the given student
course_values: Dict[str, float] = {course: alloc.effective_value(student, course) for course in courses}

# Sort the courses by their values in descending order
sorted_courses = sorted(course_values.items(), key=lambda item: item[1], reverse=True)

# Extract the course names from the sorted list of tuples
sorted_course_names = [course for course, value in sorted_courses]

return sorted_course_names

if __name__ == "__main__":
import doctest
print(doctest.testmod())

logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())

from fairpyx import Instance, divide

s1 = {"c1": 20, "c2": 15, "c3": 35, "c4": 10, "c5": 20}
s2 = {"c1": 30, "c2": 15, "c3": 20, "c4": 20, "c5": 15}
s3 = {"c1": 40, "c2": 10, "c3": 25, "c4": 10, "c5": 15}
s4 = {"c1": 10, "c2": 10, "c3": 15, "c4": 30, "c5": 35}
s5 = {"c1": 25, "c2": 20, "c3": 30, "c4": 10, "c5": 15}
agent_capacities = {"Alice": 3, "Bob": 3, "Chana": 3, "Dana": 3, "Dor": 3}
course_capacities = {"c1": 4, "c2": 4, "c3": 2, "c4": 3, "c5": 2}
valuations = {"Alice": s1, "Bob": s2, "Chana": s3, "Dana": s4, "Dor": s5}
course_order_per_student = {"Alice": ["c5", "c3", "c1", "c2", "c4"], "Bob": ["c1", "c4", "c5", "c2", "c3"], "Chana": ["c5", "c1", "c4", "c3", "c2"], "Dana": ["c3", "c4", "c1", "c5", "c2"], "Dor": ["c5", "c1", "c4", "c3", "c2"]}
tie_braking_lottery = {"Alice": 0.6, "Bob": 0.4, "Chana": 0.3, "Dana": 0.8, "Dor": 0.2}
instance = Instance(agent_capacities=agent_capacities, item_capacities=course_capacities, valuations=valuations)
divide(gale_shapley, instance=instance, course_order_per_student=course_order_per_student, tie_braking_lottery=tie_braking_lottery)
{'Alice': ['c1', 'c3', 'c5'], 'Bob': ['c1', 'c2', 'c4'], 'Chana': ['c1', 'c2', 'c4'], 'Dana': ['c2', 'c4', 'c5'], 'Dor': ['c1', 'c2', 'c3']}

1 change: 1 addition & 0 deletions fairpyx/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fairpyx.algorithms.iterated_maximum_matching import iterated_maximum_matching, iterated_maximum_matching_adjusted, iterated_maximum_matching_unadjusted
from fairpyx.algorithms.picking_sequence import round_robin, bidirectional_round_robin, serial_dictatorship
from fairpyx.algorithms.utilitarian_matching import utilitarian_matching
from fairpyx.algorithms.Gale_Shapley_pareto_dominant_market_mechanism import gale_shapley
from fairpyx.algorithms.ACEEI_algorithms.tabu_search import tabu_search
from fairpyx.algorithms.ACEEI_algorithms.ACEEI import find_ACEEI_with_EFTB
from fairpyx.algorithms.ACEEI_algorithms.find_profitable_manipulation import find_profitable_manipulation
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
minversion = "6.0"
addopts = "--doctest-modules --ignore=examples/_pweave.py --ignore=experiments --ignore=fairpyx/zalternatives"
addopts = "--doctest-modules --ignore=examples/_pweave.py --ignore=experiments --ignore=fairpyx/zalternatives --ignore=website_gale_shapley"
Loading

0 comments on commit 813c1e2

Please sign in to comment.