A local university's graduate school has a field placement that's a required component for graduation. Each student works in the field (something like a residency for medical students) for one year in addition to completing their course work.
The process of matching students with placement locations is called the match. The various placement agencies work with the university to establish field instructors and placement slots. An individual agency can have slots for one or more students. Students then indicate their first, second and third choices for placements. The university then tries to accommodate the students wishes to the extent possible. In the past this had been a manual process, but I wrote a Python program to automate this matching process and attempt to maximize collective student happiness.
The inputs
There are three input files. The first is the list of weights for the happiness
for first, second, third and lower choices. The second is a list and count of placements, and the third is a list of students and their choices. There are guaranteed to always be more placement slots than students.
weights.txt
# this file contains the weights for first, second, third, and
# lower choices. Weights must be monotonically nondecreasing
# (that is, each number must be greater than or equal to the
# number on the line above)
0
1
3
5
placements.txt
# number, name, slots
1,Agency A,3
2,Agency B,1
3,Agency C,1
4,Agency D,1
5,Agency E,2
students.txt
# comment lines begin with a # in the first column
# name, first, second, third
Annabelle,1,9,9
Margaret,1,9,3
Edward,2,9,1
Catherine,2,4,1
Marie,1,3,5
Katie,4,5,3
Michael,4,5,3
Justin,9,1,9
Jennifer,9,5,4
The program
The program reads in all three of the input files and attempts to match each student to a placement. In the first pass, the program simply assigns each student to his or her first choice placement unless there are no slots left, in which case it assigns the student to a second place slot, etc. so that at the end of the first pass, every student has a placement. Next, the program considers swapping students to maximize collective happiness. For example, one student might choose agencies A, B, C in that order, while another might have specified B, C, A. If the students were assigned to agencies C and A respectively (both third choices), swapping the two placements would increase collective happiness (to first and second choices) so this swap is made. This proceeds until no swap would increase happiness, at which point the algorithm terminates and a report is printed showing the results. Additionally, the program shows all of the possible "happiness-neutral changes" which are swaps that could be made that don't affect the total happiness score, but might be made for other reasons that the computer doesn't know about (e.g. a student who lives much closer to a particular placement, and so would find it more convenient).
Note that the program actually handles the case in which there are more students than placements, even though this should never happen in practice. It also considers anyone who did not get one of their top three choices as "unmatched" which is consistent with the university's existing terminology used for their current manual process.
I should also note that the students.txt
file above has a number of choices which are 9
which is not a valid agency. This was to simulate if the student had made a duplicate selection. That is, if they had indicated the agency D for their first, second and third choices, this would be encoded as 4,9,9.
I'm interested in comments about both the program and the algorithm.
match.py
import sys
import string
# reads in a file containing student names and preferences
def read_students(filename):
students = []
i = 0
for line in open(filename):
if (line[0] != '#'):
fields = line.strip().split(',')
student = {
'name' : fields[0],
'first' : int(fields[1]),
'second': int(fields[2]),
'third' : int(fields[3]),
'placement' : 0,
'index' : i
}
i += 1
students.append(student)
return students
# reads in a file containing weighting factors for first, second, third, and no choice
def read_weights(filename):
weights = []
for line in open(filename):
if (line[0] != '#'):
weights.append(int(line))
sw = [w for w in weights]
sw.sort()
if sw != weights:
raise ValueError
return weights
# reads in a file containing agency index, name and numbr of slots
def read_placements(filename):
placements = []
for line in open(filename):
if (line[0] != '#'):
fields = line.strip().split(',')
placement = {
'number': int(fields[0]),
'name' : fields[1],
'slots' : int(fields[2]),
'available' : int(fields[2])
}
placements.append(placement)
return placements
# adds a student to a placement if the placement exists and if there's a slot available
# returns True if the student was added, otherwise False
def add_student(student, placenum, placements):
try:
placement = [p for p in placements if p['number'] == placenum][0]
except:
return False
if (placement['available'] > 0):
placement['available'] -= 1
student['placement'] = placement['number']
return True
else:
return False
# returns the name of the placement corresponding to the passed number
# if none, then raises a KeyError
def placement_name(placements, number):
for p in placements:
if p['number'] == number:
return p['name']
raise KeyError
# first pass match
# assigns first choice if available, else second else third.
# then assigns students to remaining slots regardless of pref
def match_students(students, placements):
for s in students:
if not add_student(s, s['first'], placements):
if not add_student(s, s['second'], placements):
add_student(s, s['third'], placements)
unfilled = [p for p in placements if p['available']>0]
for p in unfilled:
unmatched = [s for s in students if s['placement'] == 0]
if (len(unmatched) > 0):
add_student(unmatched[0],p['number'],placements)
else:
return [];
return [s for s in students if s['placement']==0]
# returns unhappiness total for this particular student if testval were the assigned placement
def unhappiness(s, testval, weights):
if testval == s['first']:
return weights[0]
elif testval == s['second']:
return weights[1]
elif testval == s['third']:
return weights[2]
else:
return weights[3]
# total unhappiness of all students
def total_unhappiness(students, weights):
return sum([unhappiness(s,s['placement'],weights) for s in students])
# print the name of the organization, with a leading asterisk if it matches
# the passed pref
def print_org(choice, pref, placements):
s = " "
if choice == pref:
s = "*"
try:
return s+placement_name(placements, choice)
except:
return s+" "
# considers the result of swapping two students. If this decreases total
# unhappiness, the swap is made and True is returned. If neutral is True,
# and total unhappiness would be unchanged, a message is printed and True is
# returned. In all other cases, False is returned.
def improve_students(s1, s2, placements, weights, neutral=False):
h = [ [unhappiness(s1, s1['placement'], weights),
unhappiness(s2,s2['placement'], weights)],
[unhappiness(s1, s2['placement'], weights),
unhappiness(s2,s1['placement'], weights)]
]
if sum(h[0]) > sum(h[1]):
temp = s1['placement']
s1['placement'] = s2['placement']
s2['placement'] = temp
# print "swapping {0} and {1}".format(s1['name'], s2['name'])
return True
elif neutral and sum(h[0]) == sum(h[1]):
print "swap {0} and {1}".format(s1['name'], s2['name'])
return False
else:
# print "NOT swapping {0} and {1}".format(s1['name'], s2['name'])
return False
# prints out all students and their choices
def print_students(students, placements, weights):
print "{0:4} {1:15} {2:15} {3:15} {4:15}".format("unhp", "name", "first", "second", "third")
for st in students:
s = "{0:4} {1:15}".format(unhappiness(st,st['placement'],weights), st['name'])
s += "{0:15} ".format(print_org(st['first'],st['placement'], placements))
s += "{0:15} ".format(print_org(st['second'],st['placement'], placements))
s += "{0:15} ".format(print_org(st['third'],st['placement'], placements))
print s
# prints out all placements, number of available slots and number of total slots
def print_placements(placements):
print "{0:15} {1:4} {2:4}".format("name", "avail", "slots")
for p in placements:
print "{0:15} {1:4} {2:4}".format(p['name'], p['available'], p['slots'])
# after initial pass, attemps to optimize student placements by minimizing unhappiness
def optimize_students(students, placements, weights):
improvement = False
for s in [s for s in students if unhappiness(s, s['placement'],weights) > weights[1]]:
for s2 in [s2 for s2 in students if s2['placement']==s['first'] or s2['placement']==s['second']]:
improvement = improvement or improve_students(s, s2, placements, weights)
return improvement
# similar to optimization, but here we're just looking for swaps we could make which
# leave the unhappiness level unchanged.
def equivocate(students, placements, weights):
for s in [s for s in students if unhappiness(s, s['placement'],weights) > weights[0]]:
for s2 in [s2 for s2 in students if s2['name'] != s['name'] and s2['placement'] != s['placement']]:
if s['index'] < s2['index']:
improve_students(s, s2, placements, weights, True)
# prints the whole report include weights, students, and placements
def print_report(students, placements, weights):
print "\nWeights:"
print "{0:4} First, {1:4} Second, {2:4} Third, {3:4} unmatched".format(weights[0], weights[1], weights[2], weights[3])
print "\nStudents:"
print_students(students, placements, weights)
print "\nPlacements:"
print_placements(placements)
print "\nTotal unhappiness = {0}".format(total_unhappiness(students, weights))
# matches students with placements which minimizing total unhappiness
def match(weightsfile, placementsfile, studentsfile):
try:
weights = read_weights(weightsfile)
except ValueError:
print "ERROR: Weights must be in order from smaller to larger values."
print "Program terminated."
return
placements = read_placements(placementsfile)
students = read_students(studentsfile)
unmatched = match_students(students, placements)
print "There are {0} unmatched students {1}".format(len(unmatched), [s2['name'] for s2 in unmatched])
unoptimized = True
while (unoptimized):
unoptimized = optimize_students(students, placements, weights)
print_report(students, placements, weights)
print "\nPossible happiness-neutral changes:"
equivocate(students, placements, weights)
match("weights.txt", "placements.txt", "students.txt")
Sample output
With the inputs shown above (which is a little unusual in that there are more students than placements), the output looks like this. The *
next to an agency name means that this student is placed at this agency:
There are 1 unmatched students ['Jennifer']
Weights:
0 First, 1 Second, 3 Third, 5 unmatched
Students:
unhp name first second third
0 Annabelle *Agency A
0 Margaret *Agency A Agency C
0 Edward *Agency B Agency A
1 Catherine Agency B *Agency D Agency A
1 Marie Agency A *Agency C Agency E
1 Katie Agency D *Agency E Agency C
1 Michael Agency D *Agency E Agency C
1 Justin *Agency A
5 Jennifer Agency E Agency D
Placements:
name avail slots
Agency A 0 3
Agency B 0 1
Agency C 0 1
Agency D 0 1
Agency E 0 2
Total unhappiness = 10
Possible happiness-neutral changes:
swap Katie and Jennifer
swap Michael and Jennifer
attempt to maximize collective student happiness
other goals would be minimise student unhappiness or minimise variance in student unhappiness. \$\endgroup\$lower choice
in the input andunmatched
in the output example. \$\endgroup\$