Allocating university medics to hospital placements

Identify a resident-optimal matching in a simulated South Wales

For this tutorial, we will be using HR to solve a real-world problem.

Imagine that we represent a centralised body responsible for assigning newly qualified doctors to their hospital posts. This job is already done by computers around the world using software like matching, and now we can do it, too.

Note

The hospital capacities and all of the preferences for this instance are entirely fabricated but the hospitals are some of those from the South Wales area of the UK.

Collecting and reading the data

The data for this tutorial have been archived on Zenodo. The source code used to generate them is here.

We can load in the data as Python dictionaries with the urllib and PyYAML libraries.

import urllib
import yaml


def read_yaml_to_dict(where, filename):
    """Read in the YAML data from the URL."""

    url = "/".join((where, filename))
    with urllib.request.urlopen(url) as response:
        dictionary = yaml.safe_load(response.read())

    return dictionary


base_url = "https://zenodo.org/record/3688091/files"

resident_preferences = read_yaml_to_dict(base_url, "residents.yml")
hospital_preferences = read_yaml_to_dict(base_url, "hospitals.yml")
hospital_capacities = read_yaml_to_dict(base_url, "capacities.yml")

As it turns out, this game is fairly large. There are 200 medics (or residents) applying to 7 hospitals with a total of 210 spaces available:

num_residents = len(resident_preferences)
num_hospitals = len(hospital_preferences)
total_spaces = sum(hospital_capacities.values())

num_residents, num_hospitals, total_spaces
(200, 7, 210)

Creating the players

With the data read in, we can create the players for our game.

Tip

We don’t need to worry about cleaning the data as they were created to form a valid game instance.

This particular instance is not only too large to be done by hand, but we also won’t be creating the players manually. Instead, we will use the HospitalResident.create_from_dictionaries() method.

from matching.games import HospitalResident

game = HospitalResident.create_from_dictionaries(
    resident_preferences, hospital_preferences, hospital_capacities
)

Running the game

Now, we have a complete game instance to solve.

We have the option to find a resident- or hospital-optimal solution. In this case, as is often done in reality, we will be using the former.

solution = game.solve(optimal="resident")

Checking the matching

The solution is a dictionary-like object with hospitals as keys and lists of their matched residents as values.

for hospital, residents in solution.items():
    print(f"{hospital} ({len(residents)} / {hospital.capacity}): {residents}")
Dewi Sant (30 / 30): [067, 022, 023, 158, 139, 065, 160, 131, 011, 137, 039, 045, 013, 046, 072, 037, 086, 152, 144, 154, 130, 040, 010, 159, 083, 019, 169, 193, 168, 079]
Prince Charles (29 / 30): [027, 133, 106, 081, 051, 044, 069, 157, 110, 119, 129, 107, 135, 034, 007, 194, 198, 061, 087, 041, 183, 136, 059, 178, 009, 008, 031, 070, 026]
Prince of Wales (30 / 30): [143, 128, 048, 175, 078, 132, 151, 030, 124, 138, 088, 004, 199, 173, 017, 097, 064, 025, 112, 181, 171, 196, 111, 035, 185, 156, 140, 001, 197, 177]
Royal Glamorgan (30 / 30): [073, 118, 096, 089, 014, 126, 142, 053, 021, 018, 104, 015, 147, 153, 033, 113, 146, 076, 123, 042, 117, 024, 029, 000, 016, 134, 058, 166, 075, 174]
Royal Gwent (27 / 30): [028, 105, 115, 095, 054, 006, 120, 161, 187, 164, 091, 141, 036, 184, 071, 155, 066, 182, 189, 002, 191, 068, 090, 145, 163, 121, 180]
St. David (30 / 30): [149, 101, 150, 172, 165, 020, 049, 094, 060, 116, 056, 005, 093, 188, 043, 108, 192, 092, 167, 114, 012, 063, 077, 162, 085, 195, 032, 099, 084, 127]
University (24 / 30): [109, 003, 057, 170, 176, 100, 122, 080, 038, 082, 102, 052, 062, 055, 047, 074, 050, 179, 125, 186, 148, 103, 098, 190]

One common criterion for success when solving games like this is whether all of the medics have been assigned.

The following code allows us to see which residents (if any) were not matched to a hospital.

matched_residents = []
for _, residents in solution.items():
    for resident in residents:
        matched_residents.append(resident.name)

unmatched_residents = set(resident_preferences.keys()).difference(
    matched_residents
)
unmatched_residents
set()

We’ve done it!

Every resident has successfully been assigned to a hospital of their choice with stability and fairness.