diff --git a/README-nickF,raviP,willR_DA_TTC.md b/README-nickF,raviP,willR_DA_TTC.md new file mode 100644 index 0000000..92b8f77 --- /dev/null +++ b/README-nickF,raviP,willR_DA_TTC.md @@ -0,0 +1,74 @@ +# CSCI 4110 - Computational Social Processes +##### Final Project by Nick Fay, Ravi Panse, and Will Rigby-Hall + +### Top Trading Cycle +To run from command line as a standalone program: +``` + python toptradingcycle.py +``` +will run unit tests that are laid out in the main block of code. + +To run from another program (e.g. import): +``` + import prefpy.toptradingcycle +``` + +To use within prefpy files: +``` + from . import deferredacceptance +``` + +#### Example +``` + agents = [ + [4, 3, 2, 1, 5], + [4, 1, 2, 3, 5], + [1, 4, 3, 2, 5], + [3, 2, 1, 4, 5], + [1, 5, 2, 4, 3] + ] + + #Returns an array of assignments + #i.e., agents[0] is assigned result[0] + result = topTradingCycle(agents) +``` + +### Deferred Acceptance +To run from command line as a standalone program: +``` + python deferredacceptance.py +``` +will run unit tests that are laid out in the main block of code. + +To run from another program (e.g. import): +``` + import prefpy.deferredacceptance +``` + +To use within prefpy files: +``` + from . import deferredacceptance +``` + +#### Example +``` + side1 = [[10, 1, 2, 3, 5, 4, 6], + [20, 3, 1, 2, 4, 5, 6], + [30, 3, 1, 2, 4, 5, 6], + [40, 1, 2, 3, 4, 5, 6], + [50, 1, 2, 3, 5, 4, 6], + [60, 2, 1, 3, 4, 6, 5]] + side2 = [[1, None, 10, 20, 30, 40, 50, 60], + [2, 20, 30, None, 50, 10, 40, 60], + [3, 20, 30, 10, None, 50, 40, 60], + [4, 20, None, 30, 10, 50, 40, 60], + [5, 10, 30, None, 20, 50, 40, 60], + [6, 20, 30, 10, None, 50, 40, 60]] + #The container that holds the result from running the DA algorithm + #Where [0,1] corresponds to which side respectively is the proposing side + stablematchingcontainer = deferredAcceptance(side1, side2, 0) + + #Added a stability checker to check the final matching + #Will return True if it is stable and False if not + result = stablematchingcontainer.checkStability() +``` diff --git a/prefpy/__init__.py b/prefpy/__init__.py index 4d33ee8..3b4550b 100644 --- a/prefpy/__init__.py +++ b/prefpy/__init__.py @@ -1,4 +1,5 @@ from . import aggregate +from . import deferredacceptance from . import distributions from . import evbwie from . import gmm_mixpl_moments @@ -8,8 +9,9 @@ from . import mmgbtl from . import plackettluce from . import stats +from . import toptradingcycle from . import util -__all__ = ["aggregate", "distributions", "evbwie", "gmm_mixpl_moments", +__all__ = ["aggregate", "deferredacceptance", "distributions", "evbwie", "gmm_mixpl_moments", "gmm_mixpl_objectives", "gmm_mixpl", "gmmra", "mmgbtl", - "plackettluce", "stats", "util"] + "plackettluce", "stats", "toptradingcycle", "util"] diff --git a/prefpy/deferredacceptance.py b/prefpy/deferredacceptance.py new file mode 100644 index 0000000..8261874 --- /dev/null +++ b/prefpy/deferredacceptance.py @@ -0,0 +1,364 @@ +from . import aggregate +import sys + + +class StableMatchingAggregator(aggregate.RankAggregator): + ''' + Extended Class for Gale Shapley Algorithm Specifically + _ is used to keep private variables with public accessors + ''' + + def __init__(self, alts_list, name): + # Simple Inheritance to extend RankAggregator Class + # Small check to handle None case with integer value + if 'None' in alts_list: + x = alts_list.index('None') + alts_list[x] = '0' + super().__init__(alts_list) + self._name = name + if self._name == "None": + self._name = None + self._unmatched = True + self._matched_with = None + self._tried = [] + + # Accessor Functions for Private Class Variables + def getName(self): + return self._name + + def getUnmatched(self): + return self._unmatched + + def getMatchedWith(self): + return self._matched_with + + def getTried(self): + return self._tried + + def setUnmatched(self, val): + self._unmatched = val + + def setMatchedWith(self, val): + self._matched_with = val + + def addToTried(self, val): + self._tried.append(val) + + +class StableMatchingAggregatorContainer: + ''' + Main Class for containing two lists of StableMatchingAggregators + and perform operations on these two lists + ''' + + def __init__(self, dict1, dict2, proposer): + self._dictWhole = {**dict1, **dict2} + self._dictHalf1 = dict1 # Proposer + self._dictHalf2 = dict2 + self._proposer = proposer + self.matching = {} + + # Accessor Functions for Private Class Variables + def getWholeDict(self): + return self._dictWhole + + def getProposer(self): + return self._proposer + + def getHalfDict(self, num): + if num == 0: + return self._dictHalf1 + elif num == 1: + return self._dictHalf2 + else: + raise ValueError("Incorrect Number Specified: Must use 0 or 1") + + # Breaks a matching between two alternatives + def breakMatch(self, check): + proposer = self._proposer + matchToBreak = self.isMatchedWith(check) + alt = self.getWholeDict()[check] + if alt.getMatchedWith() is not None: + alt.setMatchedWith(None) + alt.setUnmatched(True) + print("{} is breaking with {}.".format( + check, matchToBreak)) + + # Checks who an alternative is currently matched with + def isMatchedWith(self, check): + alt = self.getWholeDict()[check] + return alt.getMatchedWith() + + # Checks if an alternative has a match + def isMatched(self, check): + alt = self.getWholeDict()[check] + if alt.getMatchedWith() is not None: + return True + return False + + # Checks if c1 or c2 is higher on alternatives preference list + def betterRanked(self, check, c1, c2): + proposer = self._proposer + alt = self.getWholeDict()[check] + for x in range(0, len(self.getHalfDict(proposer))): + if c1 == alt.alts[x]: + return c1 + elif c2 == alt.alts[x]: + return c2 + + # Matching Function + def createMatch(self, alt1, alt2): + check1 = self.getWholeDict()[alt1] + check1.setMatchedWith(alt2) + check1.setUnmatched(False) + check2 = self.getWholeDict()[alt2] + check2.setMatchedWith(alt1) + check2.setUnmatched(False) + + def betterThanNothing(self, c1, c2, proposer): + alt = self.getWholeDict()[c2] + for x in range(0, len(self.getHalfDict(proposer))): + if c1 == alt.alts[x]: + return True + elif "0" == alt.alts[x] or None == alt.alts[x]: + return False + + # Get name of preferred alternative at a certain index + def getNameFromRank(self, check, rank): + proposer = self._proposer + if proposer not in [0, 1]: + raise ValueError("Incorrect Number Specified: Must use 0 or 1") + alt = self.getWholeDict()[check] + return alt.alts[rank] + + def printResult(self): + proposer = self._proposer + print('Matching') + for key, val in self.getHalfDict(proposer).items(): + one = key + two = val.getMatchedWith() + print("{} <---> {}".format(one, two)) + + def storeMatching(self): + proposer = self._proposer + for key, val in self.getHalfDict(proposer).items(): + self.matching[key] = val.getMatchedWith() + + def checkStability(self): + proposer = self._proposer + side1 = self.getHalfDict(proposer) + side2 = self.getHalfDict(int(not proposer)) + count = 0 + for key, val in side1.items(): + # if the current proposer is unmatched, see if we can find him/her a match + if val.getUnmatched(): + for key2, val2 in side2.items(): + # if current acceptor is less preferable to None + # skip ahead and look for another acceptor + if ("0" in val.alts) and (val.alts.index(key2)>val.alts.index("0")): + continue + elif (None in val.alts) and (val.alts.index(key2)>val.alts.index(None)): + continue + # if current proposer and current acceptor are both unmatched + # see if proposer is preferred by acceptor over None + if val2.getUnmatched(): + if (None in val2.alts) and (val2.alts.index(key) len(alts_list) or id < 0: + raise ValueError( + "id must be in range [1, n] where n is the number of alternatives") + + # Simple inheritance to extend RankAggregator class + super().__init__(alts_list) + self._id = id + + self._allocation = id # alternative currently allocated to this aggregator + self._reference = None # id of aggregator allocated this aggregator's top choice + + # Accessor functions for private class variables + def getId(self): + return self._id + + def getAllocation(self): + return self._allocation + + def getReference(self): + return self._reference + + def setAllocation(self, val): + self._allocation = val + + def setReference(self, val): + self._reference = val + + +class TTCAggregatorContainer: + ''' + Main class that holds a list of TTCAggregators + and performs operations on that list + ''' + + def __init__(self, aggregators): + self._master_aggregators = {} + self._aggregators = {} + self._allocations = [] + for aggregator in aggregators: + self._aggregators[aggregator.getId()] = aggregator + self._allocations.append(aggregator.getAllocation()) + + # Accessor functions for private class variables + def getAggregators(self): + return self._aggregators.values() + + def getNumAggregators(self): + return len(self._aggregators) + + # Return a list of all final allocations of aggregators + def getFinalAllocations(self): + allocations = [None] * len(self._master_aggregators) + for id, aggregator in self._master_aggregators.items(): + allocations[id - 1] = aggregator.getAllocation() + return allocations + + # Remove an aggregator from the trade + def removeAggregator(self, id): + self._master_aggregators[id] = self._aggregators[id] + self._allocations.remove(id) + self._aggregators.pop(id) + + # Update each aggregator's reference + def updateReferences(self): + if len(self._aggregators) == 0: + return + for aggregator1 in self._aggregators.values(): + topChoice = [ + x for x in aggregator1.alts if x in self._allocations][0] + for aggregator2 in self._aggregators.values(): + if aggregator2.getAllocation() == topChoice: + aggregator1.setReference(aggregator2.getId()) + break + + # Update the allocations of all aggregators based on a given cycle + def updateAllocations(self, cycle): + if len(cycle) == 1: + self.removeAggregator(cycle[0]) + else: + firstAllocation = self._aggregators[cycle[0]].getAllocation() + # set each aggregator's allocation to that of the next allocation + # in the cycle and remove it + for i in range(0, len(cycle) - 1): + self._aggregators[cycle[i]].setAllocation( + self._aggregators[cycle[i + 1]].getAllocation()) + self.removeAggregator(cycle[i]) + self._aggregators[cycle[-1]].setAllocation(firstAllocation) + self.removeAggregator(cycle[-1]) + self.updateReferences() + + # Returns a 2D array containing the cycles between aggregators in the + # container + def getCycles(self): + # convert the array of aggregators into a more usable graph form + G = {} + for aggregator in self._aggregators.values(): + G[aggregator.getId()] = aggregator.getReference() + + cycles = [] + cycle = [] + next = list(G.keys())[0] + # parse the graph looking for cycles + # the graph only contains vertices that have not been visited + while True: + if next not in G: + if current == next: + cycles.append([next]) + elif cycle[0] == next: + cycles.append(cycle) + if len(G) == 0: + break + cycle = [] + next = list(G.keys())[0] + + current = next + cycle.append(current) + next = G[current] + G.pop(current) + + return cycles + + +def topTradingCycle(preferences): + ''' + preferences: 2D array of preferences for each agent + ''' + + # create TTCAggregator objects and container from preferences + aggregators = [] + n = len(preferences) + for profile in preferences: + id = len(aggregators) + 1 + aggregator = TTCAggregator(profile, id) + aggregators.append(aggregator) + + aggregators = TTCAggregatorContainer(aggregators) + + # main algorithm + k = 0 + while aggregators.getNumAggregators() > 0: + k += 1 + aggregators.updateReferences() + TTCPrinter(k, aggregators) + cycles = aggregators.getCycles() + for cycle in cycles: + aggregators.updateAllocations(cycle) + + return aggregators.getFinalAllocations() + + +def TTCPrinter(k, aggregators): + print("Iteration {}:".format(k)) + for aggregator in aggregators.getAggregators(): + print(" {} @ {} ---> {}".format(aggregator.getId(), + aggregator.getAllocation(), aggregator.getReference())) + + +def main(): + print("Executing tests") + + assert_message = "Test %i failed, allocation does not match expected result" + + print("Testing TTC...") + print() + + # Test 1 + print("Test Case 1:") + agents = [ + [3, 2, 1], + [1, 2, 3], + [2, 3, 1] + ] + for agent in agents: + print(agent) + + print() + result = [3, 1, 2] + ttc = topTradingCycle(agents) + print("Test Case 1 result:", ttc) + print() + assert (ttc == result), assert_message % 1 + + # Test 2 + print("Test Case 2:") + agents = [ + [4, 3, 2, 1, 5], + [4, 1, 2, 3, 5], + [1, 4, 3, 2, 5], + [3, 2, 1, 4, 5], + [1, 5, 2, 4, 3] + ] + for agent in agents: + print(agent) + + print() + result = [4, 2, 1, 3, 5] + ttc = topTradingCycle(agents) + print("Test Case 2 result:", ttc) + print() + assert (ttc == result), assert_message % 2 + + # Test 3 + print("Test Case 3:") + agents = [ + [3, 2, 4, 1, 5, 6], + [3, 5, 6, 1, 2, 4], + [3, 1, 2, 4, 5, 6], + [2, 5, 6, 4, 1, 3], + [1, 3, 2, 4, 5, 6], + [2, 4, 5, 6, 1, 3] + ] + for agent in agents: + print(agent) + + print() + result = [2, 5, 3, 6, 1, 4] + ttc = topTradingCycle(agents) + print("Test Case 3 result:", ttc) + print() + assert (ttc == result), assert_message % 3 + + # Test 4 + print("Test Case 4:") + agents = [] + for agent in agents: + print(agent) + + print() + result = [] + ttc = topTradingCycle(agents) + print("Test Case 4 result:", ttc) + print() + assert (ttc == result), assert_message % 4 + + print("TTC passed all tests!") + +if __name__ == "__main__": + main()