diff --git a/LICENSE b/LICENSE index f288702d..886f2062 100644 --- a/LICENSE +++ b/LICENSE @@ -672,3 +672,29 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + +Notice that `group_genetic.py` and `workbook.py` is from +https://github.com/yeeunmariakim/gatorgrouper/blob/master +under MIT license: + +MIT License + +Copyright (c) 2019 Maria Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pipfile b/Pipfile index e2c25913..6090a4ad 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,8 @@ mixer = "*" pylint = "*" mutmut = "*" hypothesis = "*" +numpy = "*" +ansicolors = "*" awsebcli = "*" [packages] @@ -25,6 +27,7 @@ py = "*" django = "==2.1.5" networkx = "*" social-auth-app-django = "*" +pandas = "*" [pipenv] allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index 6800ce01..60d9f127 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -65,6 +65,34 @@ "index": "pypi", "version": "==2.2" }, + "numpy": { + "hashes": [ + "sha256:1980f8d84548d74921685f68096911585fee393975f53797614b34d4f409b6da", + "sha256:22752cd809272671b273bb86df0f505f505a12368a3a5fc0aa811c7ece4dfd5c", + "sha256:23cc40313036cffd5d1873ef3ce2e949bdee0646c5d6f375bf7ee4f368db2511", + "sha256:2b0b118ff547fecabc247a2668f48f48b3b1f7d63676ebc5be7352a5fd9e85a5", + "sha256:3a0bd1edf64f6a911427b608a894111f9fcdb25284f724016f34a84c9a3a6ea9", + "sha256:3f25f6c7b0d000017e5ac55977a3999b0b1a74491eacb3c1aa716f0e01f6dcd1", + "sha256:4061c79ac2230594a7419151028e808239450e676c39e58302ad296232e3c2e8", + "sha256:560ceaa24f971ab37dede7ba030fc5d8fa173305d94365f814d9523ffd5d5916", + "sha256:62be044cd58da2a947b7e7b2252a10b42920df9520fc3d39f5c4c70d5460b8ba", + "sha256:6c692e3879dde0b67a9dc78f9bfb6f61c666b4562fd8619632d7043fb5b691b0", + "sha256:6f65e37b5a331df950ef6ff03bd4136b3c0bbcf44d4b8e99135d68a537711b5a", + "sha256:7a78cc4ddb253a55971115f8320a7ce28fd23a065fc33166d601f51760eecfa9", + "sha256:80a41edf64a3626e729a62df7dd278474fc1726836552b67a8c6396fd7e86760", + "sha256:893f4d75255f25a7b8516feb5766c6b63c54780323b9bd4bc51cdd7efc943c73", + "sha256:972ea92f9c1b54cc1c1a3d8508e326c0114aaf0f34996772a30f3f52b73b942f", + "sha256:9f1d4865436f794accdabadc57a8395bd3faa755449b4f65b88b7df65ae05f89", + "sha256:9f4cd7832b35e736b739be03b55875706c8c3e5fe334a06210f1a61e5c2c8ca5", + "sha256:adab43bf657488300d3aeeb8030d7f024fcc86e3a9b8848741ea2ea903e56610", + "sha256:bd2834d496ba9b1bdda3a6cf3de4dc0d4a0e7be306335940402ec95132ad063d", + "sha256:d20c0360940f30003a23c0adae2fe50a0a04f3e48dc05c298493b51fd6280197", + "sha256:d3b3ed87061d2314ff3659bb73896e622252da52558f2380f12c421fbdee3d89", + "sha256:dc235bf29a406dfda5790d01b998a1c01d7d37f449128c0b1b7d1c89a84fae8b", + "sha256:fb3c83554f39f48f3fa3123b9c24aecf681b1c289f9334f8215c1d3c8e2f6e5b" + ], + "version": "==1.16.2" + }, "oauthlib": { "hashes": [ "sha256:0ce32c5d989a1827e3f1148f98b9085ed2370fc939bf524c9c851d8714797298", @@ -72,6 +100,32 @@ ], "version": "==3.0.1" }, + "pandas": { + "hashes": [ + "sha256:071e42b89b57baa17031af8c6b6bbd2e9a5c68c595bc6bf9adabd7a9ed125d3b", + "sha256:17450e25ae69e2e6b303817bdf26b2cd57f69595d8550a77c308be0cd0fd58fa", + "sha256:17916d818592c9ec891cbef2e90f98cc85e0f1e89ed0924c9b5220dc3209c846", + "sha256:2538f099ab0e9f9c9d09bbcd94b47fd889bad06dc7ae96b1ed583f1dc1a7a822", + "sha256:366f30710172cb45a6b4f43b66c220653b1ea50303fbbd94e50571637ffb9167", + "sha256:42e5ad741a0d09232efbc7fc648226ed93306551772fc8aecc6dce9f0e676794", + "sha256:4e718e7f395ba5bfe8b6f6aaf2ff1c65a09bb77a36af6394621434e7cc813204", + "sha256:4f919f409c433577a501e023943e582c57355d50a724c589e78bc1d551a535a2", + "sha256:4fe0d7e6438212e839fc5010c78b822664f1a824c0d263fd858f44131d9166e2", + "sha256:5149a6db3e74f23dc3f5a216c2c9ae2e12920aa2d4a5b77e44e5b804a5f93248", + "sha256:627594338d6dd995cfc0bacd8e654cd9e1252d2a7c959449228df6740d737eb8", + "sha256:83c702615052f2a0a7fb1dd289726e29ec87a27272d775cb77affe749cca28f8", + "sha256:8c872f7fdf3018b7891e1e3e86c55b190e6c5cee70cab771e8f246c855001296", + "sha256:90f116086063934afd51e61a802a943826d2aac572b2f7d55caaac51c13db5b5", + "sha256:a3352bacac12e1fc646213b998bce586f965c9d431773d9e91db27c7c48a1f7d", + "sha256:bcdd06007cca02d51350f96debe51331dec429ac8f93930a43eb8fb5639e3eb5", + "sha256:c1bd07ebc15285535f61ddd8c0c75d0d6293e80e1ee6d9a8d73f3f36954342d0", + "sha256:c9a4b7c55115eb278c19aa14b34fcf5920c8fe7797a09b7b053ddd6195ea89b3", + "sha256:cc8fc0c7a8d5951dc738f1c1447f71c43734244453616f32b8aa0ef6013a5dfb", + "sha256:d7b460bc316064540ce0c41c1438c416a40746fd8a4fb2999668bf18f3c4acf1" + ], + "index": "pypi", + "version": "==0.24.2" + }, "pathlib": { "hashes": [ "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f" @@ -94,6 +148,13 @@ ], "version": "==1.7.1" }, + "python-dateutil": { + "hashes": [ + "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", + "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" + ], + "version": "==2.8.0" + }, "python3-openid": { "hashes": [ "sha256:0086da6b6ef3161cfe50fb1ee5cceaf2cda1700019fda03c2c5c440ca6abe4fa", @@ -147,6 +208,13 @@ ], "version": "==3.1.0" }, + "sqlparse": { + "hashes": [ + "sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177", + "sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873" + ], + "version": "==0.3.0" + }, "urllib3": { "hashes": [ "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", @@ -156,6 +224,14 @@ } }, "develop": { + "ansicolors": { + "hashes": [ + "sha256:00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187", + "sha256:99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0" + ], + "index": "pypi", + "version": "==1.1.8" + }, "appdirs": { "hashes": [ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", @@ -531,6 +607,42 @@ "index": "pypi", "version": "==0.3.1" }, + "numpy": { + "hashes": [ + "sha256:1980f8d84548d74921685f68096911585fee393975f53797614b34d4f409b6da", + "sha256:22752cd809272671b273bb86df0f505f505a12368a3a5fc0aa811c7ece4dfd5c", + "sha256:23cc40313036cffd5d1873ef3ce2e949bdee0646c5d6f375bf7ee4f368db2511", + "sha256:2b0b118ff547fecabc247a2668f48f48b3b1f7d63676ebc5be7352a5fd9e85a5", + "sha256:3a0bd1edf64f6a911427b608a894111f9fcdb25284f724016f34a84c9a3a6ea9", + "sha256:3f25f6c7b0d000017e5ac55977a3999b0b1a74491eacb3c1aa716f0e01f6dcd1", + "sha256:4061c79ac2230594a7419151028e808239450e676c39e58302ad296232e3c2e8", + "sha256:560ceaa24f971ab37dede7ba030fc5d8fa173305d94365f814d9523ffd5d5916", + "sha256:62be044cd58da2a947b7e7b2252a10b42920df9520fc3d39f5c4c70d5460b8ba", + "sha256:6c692e3879dde0b67a9dc78f9bfb6f61c666b4562fd8619632d7043fb5b691b0", + "sha256:6f65e37b5a331df950ef6ff03bd4136b3c0bbcf44d4b8e99135d68a537711b5a", + "sha256:7a78cc4ddb253a55971115f8320a7ce28fd23a065fc33166d601f51760eecfa9", + "sha256:80a41edf64a3626e729a62df7dd278474fc1726836552b67a8c6396fd7e86760", + "sha256:893f4d75255f25a7b8516feb5766c6b63c54780323b9bd4bc51cdd7efc943c73", + "sha256:972ea92f9c1b54cc1c1a3d8508e326c0114aaf0f34996772a30f3f52b73b942f", + "sha256:9f1d4865436f794accdabadc57a8395bd3faa755449b4f65b88b7df65ae05f89", + "sha256:9f4cd7832b35e736b739be03b55875706c8c3e5fe334a06210f1a61e5c2c8ca5", + "sha256:adab43bf657488300d3aeeb8030d7f024fcc86e3a9b8848741ea2ea903e56610", + "sha256:bd2834d496ba9b1bdda3a6cf3de4dc0d4a0e7be306335940402ec95132ad063d", + "sha256:d20c0360940f30003a23c0adae2fe50a0a04f3e48dc05c298493b51fd6280197", + "sha256:d3b3ed87061d2314ff3659bb73896e622252da52558f2380f12c421fbdee3d89", + "sha256:dc235bf29a406dfda5790d01b998a1c01d7d37f449128c0b1b7d1c89a84fae8b", + "sha256:fb3c83554f39f48f3fa3123b9c24aecf681b1c289f9334f8215c1d3c8e2f6e5b" + ], + "version": "==1.16.2" + }, + "oauth2client": { + "hashes": [ + "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", + "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6" + ], + "index": "pypi", + "version": "==4.1.3" + }, "packaging": { "hashes": [ "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", diff --git a/gatorgrouper/utils/constants.py b/gatorgrouper/utils/constants.py index 1f701446..f2b62cd5 100644 --- a/gatorgrouper/utils/constants.py +++ b/gatorgrouper/utils/constants.py @@ -4,6 +4,7 @@ ALGORITHM_ROUND_ROBIN = "rrobin" ALGORITHM_RANDOM = "random" ALGORITHM_GRAPH = "graph" +ALGORITHM_GENETIC = "genetic" DEFAULT_METHOD = ALGORITHM_RANDOM DEFAULT_ABSENT = "" DEFAULT_PREFERENCES = None @@ -18,3 +19,9 @@ # recognizer EMPTY_STRING = "" ERROR = "error:" + +# Define configuration variables + +DEFAULT_GROUP_SIZE = 2 +WORKBOOK = "GatorGrouper_(Responses).csv" +# WORKBOOK_CSV = WORKBOOK.replace(" ", "_") + ".csv" diff --git a/gatorgrouper/utils/group_genetic.py b/gatorgrouper/utils/group_genetic.py new file mode 100644 index 00000000..e3323db2 --- /dev/null +++ b/gatorgrouper/utils/group_genetic.py @@ -0,0 +1,485 @@ +"""Generate grouping based on student skills and preferences. +This code is under MIT license from +https://github.com/yeeunmariakim/gatorgrouper/blob/master/genetic_algorithm.py""" +from typing import List +import random +import math +import numpy as np +from gatorgrouper.utils import constants +from gatorgrouper.utils import workbook +# from colors import bold + + +class Student: + """Represent student.""" + + def __init__(self, email: str, skills: List[int], preferences: List[str]): + self.email = email + self.skills = skills + self.preferences = preferences + + def __str__(self): + student_str = self.email + "\n" + student_str += "\tPreferences: " + str(self.preferences) + "\n" + student_str += "\tSkills: " + str(self.skills) + return student_str + + def __repr__(self): + return self.email + + def __eq__(self, other): + if isinstance(self, other.__class__): + return self.email == other.email + return NotImplemented + + def __hash__(self): + return hash(self.email) + + +class Individual: + # pylint: disable=too-few-public-methods + """Represent individual.""" + + def __init__(self, grouping: List[List[Student]], fitness): + self.grouping = grouping + self.fitness = fitness + + def __str__(self): + grouping_str = "" + for number, group in enumerate(self.grouping): + grouping_str += "Group {}".format(number) + "\n" + for student in group: + grouping_str += str(student) + "\n" + return bold("Grouping\n") + grouping_str + bold("Fitness\n") + str(self.fitness) + + +class Fitness: + """Represent fitness. Variables range from 0 to 1.""" + + def __init__(self, preference, balance, fairness): + self.preference = preference + self.balance = balance + self.fairness = fairness + # Need to do: Can give weights to each variable + self.value = 0.5 * preference + 3.0 * balance + 1.5 * fairness + + def __gt__(self, other): + return self.value > other.value + + def __str__(self): + string = "Preference: " + str(self.preference) + "\n" + string += "Balance: " + str(self.balance) + "\n" + string += "Fairness: " + str(self.fairness) + "\n" + string += "Value: " + str(self.value) + "\n" + return string + + +best_grouping = list() +best_fitness = Fitness(0, 0, 0) + + +def create(): + """Create the groups of student""" + + students_to_group = ["a","b","c","d"] + random.shuffle(students_to_group) + + grouping = list() + + for _ in range(DEFAULT_GRPSIZE): + grouping.append(list()) + + for index, student in enumerate(students_to_group): + grouping[index % DEFAULT_GRPSIZE].append(student) + + if grouping < 1: + print("CREATED TOO SMALL GROUPING") + + # print("CREATED GROUPNG: " + str(grouping)) + return grouping + + +def evolve( + # pylint: disable = C0330 + # Black would reformat the code in the way that does not pass pylint + population_size, + mutation_rate, + elitism_rate, + create_rate, + crossover_rate, + mutations, +): + """population_size: int, mutation_rate: float, crossover_rate: float, fitness, + mutations, create""" + # pylint: disable=global-statement + global best_grouping + global best_fitness + + print("in evolve") + population = [create() for _ in range(population_size)] + population = list( + map( + lambda grouping: Individual(grouping, calculate_fitness(grouping)), + population, + ) + ) + + gen = 0 + while gen < 200: + # spawn next generation + # print("Start of gen {}".format(gen)) + gen += 1 + population = spawn( + population, + mutation_rate, + elitism_rate, + create_rate, + crossover_rate, + mutations, + ) + population = list( + map( + lambda grouping: Individual(grouping, calculate_fitness(grouping)), + population, + ) + ) + + dupl = False + for ind in population: + seen = set() + for group in ind.grouping: + for student in group: + if student in seen: + print("MAIN SCAN DUPLICATE") + print(ind) + dupl = True + seen.add(student) + if dupl: + exit() + + avg = 0 + for ind in population: + avg += ind.fitness.value + avg /= population_size + print("AVG Fitness of gen {} is {}".format(gen, avg)) + + print("Best grouping: " + str(best_grouping)) + print_grouping(best_grouping) + print("Best fitness: " + str(best_fitness)) + + +def crossover(individual_one, individual_two): + + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + + """Add smaller groupings of students to the larger groups""" + + grouping_one = individual_one.grouping[:] + grouping_two = individual_two.grouping[:] + + # print("crossing {} with {}".format(grouping_one, grouping_two)) + + group_count = len(grouping_one) + offspring = list() + + # add groups that appear in both groupings + for one in grouping_one: + for two in grouping_two: + if set(one) == set(two): + dupl = False + for group in offspring: + for student in one: + if student in group: + dupl = True + break + if dupl: + break + if not dupl: + offspring.append(one) + + # print("added equals: {}".format(offspring)) + + # step through groupings one and two, adding a whole group when possible to offspring. + # Alternate between groupings after every successful addition + on_one = True + index_one = 0 + index_two = 0 + while index_one < len(grouping_one) or index_two < len(grouping_two): + if on_one and index_one < len(grouping_one): + dupl = False + for student in grouping_one[index_one]: + for group in offspring: + if student in group: + dupl = True + break + if dupl: + break + if not dupl: + offspring.append(grouping_one[index_one]) + # print("appending {} from one".format(grouping_one[index_one])) + del grouping_one[index_one] + on_one = False + else: + index_one += 1 + elif on_one: + on_one = False + elif not on_one and index_two < len(grouping_two): + dupl = False + for student in grouping_two[index_two]: + for group in offspring: + if student in group: + dupl = True + break + if dupl: + break + if not dupl: + offspring.append(grouping_two[index_two]) + # print("appending {} from two".format(grouping_two[index_two])) + del grouping_two[index_two] + on_one = True + else: + index_two += 1 + elif not on_one: + on_one = True + + # print("Finished appending all possible, list: {}".format(offspring)) + + num_groups_so_far = len(offspring) + num_groups_left = group_count - num_groups_so_far + + # remove students alread grouped again + students_to_group = workbook.STUDENTS[:] + for group in offspring: + for student in group: + students_to_group.remove(student) + + # print("Remaining: {}".format(students_to_group)) + + # initialize groups for remainder groups + remaining = list() + + for _ in range(num_groups_left): + remaining.append(list()) + + for index, student in enumerate(students_to_group): + remaining[index % num_groups_left].append(student) + + for group in remaining: + offspring.append(group) + + if len(offspring) != group_count: + print("CROSSED OVER GROUPING NOT SAME SIZE") + + return offspring + + +def mutate(mutations, grouping: List[List[Student]]): + """Mutate a grouping with a randomly chosen mutation.""" + return random.choice(mutations)(grouping) + + +def spawn( + # pylint: disable=C0330 + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + # Black would reformat the code in the way that does not pass pylint + prev_population: List[Individual], + mutation_rate: float, + elitism_rate: float, + create_rate: float, + # pylint: disable = W0613 + crossover_rate: float, + mutations, +): + """Spawn new population""" + count = len(prev_population) + + next_population = list() + + elite_count = math.floor(count * elitism_rate) + create_count = math.floor(count * create_rate) + crossover_count = count - elite_count - create_count + + for _ in range(elite_count): + toap = select(prev_population).grouping + # print("appending {}".format(toap)) + seen = set() + dupl = False + for group in toap: + for student in group: + if student in seen: + print("DUPLICATE") + print(toap) + dupl = True + seen.add(student) + if dupl: + print("GOT DUPLICATE FROM ELITE") + exit() + next_population.append(toap) + + for _ in range(create_count): + toap = create() + # print("appending {}".format(toap)) + seen = set() + dupl = False + for group in toap: + for student in group: + if student in seen: + print("DUPLICATE") + print(toap) + dupl = True + seen.add(student) + if dupl: + print("GOT DUPLICATE FROM CREATE") + exit() + next_population.append(toap) + + for _ in range(crossover_count): + parent_one = select(prev_population) + parent_two = select(prev_population) + toap = crossover(parent_one, parent_two) + # print("appending {}".format(toap)) + seen = set() + dupl = False + for group in toap: + for student in group: + if student in seen: + print("DUPLICATE") + print(toap) + dupl = True + seen.add(student) + if dupl: + print("GOT DUPLICATE FROM CROSSOVER") + exit() + next_population.append(toap) + + for i, ind in enumerate(next_population): + r = random.random() + if r < mutation_rate: + next_population[i] = mutate(mutations, ind) + seen = set() + dupl = False + for group in next_population[i]: + for student in group: + if student in seen: + print("DUPLICATE") + print(next_population[i]) + dupl = True + seen.add(student) + if dupl: + print("GOT DUPLICATE FROM MUTATE") + exit() + return next_population + + +def select(population: List[Individual]): + """Select random individuals from population and find most fit tournament-style.""" + SELECT_NUMBER = 8 # math.floor(len(population) / 3) + selected = random.sample(population, SELECT_NUMBER) + while len(selected) > 1: + individual_one = selected.pop(0) + individual_two = selected.pop(0) + if individual_one.fitness > individual_two.fitness: + selected.append(individual_one) + else: + selected.append(individual_two) + return selected[0] + + +def calculate_fitness(grouping: List[List[Student]]): + # pylint: disable=R0914 + """Calculate compatibility between students""" + # pylint: disable=too-many-branches + # pylint: disable=global-statement + global best_grouping + global best_fitness + + # STUDENT PREFERENCES + preferences_count = 0 + for group in grouping: + for student in group: + preferences_count += len(student.preferences) + + preferences_respected = 0 + for group in grouping: + for student in group: + for other in group: + if other.email in student.preferences: + preferences_respected += 1 + + preferences_value = preferences_respected / preferences_count # 0 to 1 + + # SKILL BALANCE, measured by the coefficient of variation of skills across group + # Reference: https://bit.ly/2Hh1LXI + # e.g. group = [[1, 2, 3, 4, 5], + # [1, 2, 3, 4, 5]] + # group_skills = [1, 2, 3, 4, 5] + # group_skill_avg = 3 + # group_skill_std = 1.41 + # group_skills_coef = 1.41 / 3 = 0.47 + + if grouping < 1: + print("GROUPING IS TOO SMALL") + print(grouping) + for group in grouping: + if group < 1: + print("GROUP IS TOO SMALL") + print(grouping) + + skills_by_group = [] + for _ in range(len(grouping)): + skills_by_group += [ + [0] * len(grouping[0][0].skills) + ] # assumes there is at least one student in one group + + for group_index, group in enumerate(grouping): + skills_within_group = [0] * len(group[0].skills) + for student in group: + for skill_index, skill in enumerate(student.skills): + skills_within_group[skill_index] += skill + for skill_total in skills_within_group: + skill_total = skill_total / len(group) # get average + skills_by_group[group_index] = skills_within_group + + skills_coef_by_group = list() + for skills in skills_by_group: + # print("skills: " + str(skills)) + # print("mean of skills: " + str(np.mean(skills))) + # print("stdev of skills: " + str(np.std(skills))) + skills_coef_by_group.append(np.std(skills) / np.mean(skills)) + + balance_value = 1 - np.mean(skills_coef_by_group) + + # SKILL FAIRNESS, measured by the coefficient of variation of skills across grouping + # e.g. group_1_skills = [1, 2, 3, 4, 5] + # group_2_skills = [5, 4, 3, 2, 1] + # grouping_skills_avg = [3, 3, 3, 3, 3] + # grouping_skills_std = [2, 1, 0, 1, 2] + # grouping_coef = [(2/3), (1/3), (0/3), (1/3), (2/3)] + # grouping_coef_avg = 0.396 + + # transpose the list of lists + skills_by_group_transposed = list(map(list, skills_by_group)) + skills_coef_by_grouping = list() + for skills in skills_by_group_transposed: + skills_coef_by_grouping.append(np.std(skills) / np.mean(skills)) + + fairness_value = 1 - np.mean(skills_coef_by_grouping) + + current_fitness = Fitness(preferences_value, balance_value, fairness_value) + if current_fitness > best_fitness: + best_fitness = current_fitness + best_grouping = grouping + + return current_fitness + + +def print_grouping(grouping): + """Print out the groups""" + for index, group in enumerate(grouping): + print("Group " + str(index) + "\n") + for student in group: + print(student) diff --git a/gatorgrouper/utils/mutations.py b/gatorgrouper/utils/mutations.py new file mode 100644 index 00000000..760397b4 --- /dev/null +++ b/gatorgrouper/utils/mutations.py @@ -0,0 +1,33 @@ +import sys +import random +from typing import List +from gatorgrouper.utils import group_genetic + + +def swap(grouping): + # print("MUTATION") + group_count = len(grouping) + # print("TOTAL GROUPS: {}".format(group_count)) + # print("BEFORE: {}".format(grouping)) + first, second = random.sample(range(len(grouping)), 2) + first_index = random.randrange(len(grouping[first])) + second_index = random.randrange(len(grouping[second])) + # print("swapping student {} in group {} with student {} in group {}".format(first_index, first, second_index, second)) + temp = grouping[second][second_index] + grouping[second][second_index] = grouping[first][first_index] + grouping[second][second_index] = temp + # grouping[first][first_index], grouping[second][second_index] = grouping[second][second_index], grouping[first][first_index] + # print("AFTER: {}".format(grouping)) + + return grouping + + +def multi_swap(grouping): + num_swaps = random.randrange(1, 6) + for _ in range(num_swaps): + grouping = swap(grouping) + return grouping + + +def get(): + return [swap, multi_swap] diff --git a/gatorgrouper/utils/parse_arguments.py b/gatorgrouper/utils/parse_arguments.py index 96acb3fd..84af7235 100644 --- a/gatorgrouper/utils/parse_arguments.py +++ b/gatorgrouper/utils/parse_arguments.py @@ -49,6 +49,7 @@ def parse_arguments(args: List[str]) -> argparse.Namespace: constants.ALGORITHM_GRAPH, constants.ALGORITHM_ROUND_ROBIN, constants.ALGORITHM_RANDOM, + constants.ALGORITHM_GENETIC, ], default=constants.DEFAULT_METHOD, required=False, diff --git a/gatorgrouper/utils/workbook.py b/gatorgrouper/utils/workbook.py new file mode 100644 index 00000000..2e778d21 --- /dev/null +++ b/gatorgrouper/utils/workbook.py @@ -0,0 +1,71 @@ +"""Integrate GatorGrouper with Google Sheets.""" + +import csv +import math +import logging +import gspread +import pandas as pd +from oauth2client.service_account import ServiceAccountCredentials + +from gatorgrouper.utils import group_genetic +from gatorgrouper.utils import constants + + +EMAIL_COL = None +PREFERENCES_COL = None +SKILLS_COLS = set() + +STUDENTS = None +GROUPING_SIZE = None + +def get(group_size): + """Retrieve data from Google Sheets and write to a CSV file.""" + + global EMAIL_COL + global PREFERENCES_COL + global SKILLS_COLS + + # formatted_records = list() + # for entry in records: + # formatted_entry = list() + # for index, (question, response) in enumerate(entry.items()): + # if question == 'Email Address': + # EMAIL_COL = index - 1 # subtracting one because timestamp column not collected + # formatted_entry.append(response) + # elif "prefer" in question: + # PREFERENCES_COL = index - 1 + # formatted_entry.append(response) + # elif "skill" in question: + # SKILLS_COLS.add(index - 1) + # formatted_entry.append(response) + # formatted_records.append(formatted_entry) + + global STUDENTS + global GROUPING_SIZE + + # EMAIL_COL = 0 + # PREFERENCES_COL = 1 + # SKILLS_COLS = [2, 3, 4, 5, 6] + + DATA = pd.read_csv(constants.WORKBOOK_CSV, header=None) + + EMAILS = DATA.iloc[:, EMAIL_COL] + + STUDENTS = list() + for current_row, email in enumerate(EMAILS): + skills = list() + for skill_col in SKILLS_COLS: + skills.append(DATA.iat[current_row, skill_col]) + preferences_str = DATA.iat[current_row, PREFERENCES_COL] + + if isinstance(preferences_str, float) and math.isnan(preferences_str): + preferences = [] + else: + preferences = preferences_str.replace(" ", "").split(",") + + STUDENTS.append(Student(email, skills, preferences)) + + # for student in STUDENTS: + # print(str(student) + "\n") + + GROUPING_SIZE = math.floor(len(STUDENTS) / group_size) diff --git a/gatorgrouper_cli.py b/gatorgrouper_cli.py index 79c86484..6945ab88 100644 --- a/gatorgrouper_cli.py +++ b/gatorgrouper_cli.py @@ -6,6 +6,9 @@ from gatorgrouper.utils import parse_arguments from gatorgrouper.utils import read_student_file from gatorgrouper.utils import display +from gatorgrouper.utils import constants +from gatorgrouper.utils import group_genetic +from gatorgrouper.utils import mutations from gatorgrouper.utils import run