def worst_learners(current: Solution, generator: Generator): """ Computes the costs for each learner, as the difference between their best and current assignments. Using a skewed distribution, q of the worst cost learners are randomly selected and removed from the solution. """ destroyed = deepcopy(current) problem = Problem() costs = np.zeros(problem.num_learners) assigned_activities = {} for activity in destroyed.activities: for learner in activity.learners: assigned_activities[learner.id] = activity learner_ids = activity.learner_ids() # The cost is the cost of the best possible assignment for this # learner, minus the cost of the current assignment (including # self-study penalty, if applicable). The larger the cost, the more # suboptimal the current assignment. best_module_id = problem.most_preferred[learner_ids, 0] curr_module_id = activity.module.id costs[learner_ids] = problem.preferences[learner_ids, best_module_id] costs[learner_ids] -= problem.preferences[learner_ids, curr_module_id] if activity.is_self_study(): # Per the paper: pref(best) - (pref(curr) - <maybe penalty>) # = pref(best) - pref(curr) + <maybe penalty>. costs[learner_ids] += problem.penalty learners = np.argsort(costs) learners = learners[-random_selection(generator)] for learner_id in learners: activity = assigned_activities[learner_id] if activity.can_remove_learner(): learner = problem.learners[learner_id] destroyed.unassigned.add(learner) activity.remove_learner(learner) return destroyed
def create_single_customer_route(customer: int) -> Route: """ Creates a single customer route for the passed-in customer. This route visits the DEPOT, then the customer, and returns to the DEPOT. O(1). """ problem = Problem() # After depot, and after customer: two configurations in total. stacks = [Stacks(problem.num_stacks) for _ in range(2)] # We place the deliveries and pickups in the shortest stack - this # does not really matter much, as each stack is empty at this point # anyway. stacks[0].shortest_stack().push_rear(problem.demands[customer]) stacks[1].shortest_stack().push_rear(problem.pickups[customer]) return Route([customer], stacks)
def classrooms_to_modules(solution: List[Tuple]) -> bool: """ Verifies the number of classrooms assigned is less than the total number of classrooms available, and each classroom is assigned to *one* module only. """ problem = Problem() classroom_modules = defaultdict(set) for assignment in solution: _, module, classroom, _ = assignment classroom_modules[classroom].add(module) if len(classroom_modules) > len(problem.classrooms): return False return all(len(value) == 1 for value in classroom_modules.values())
def _setup_decision_variables(solver: Model): """ Prepares and applies the decision variables to the model. """ problem = Problem() assignment_problem = [ list(range(len(problem.learners))), list(range(len(problem.modules))), list(range(len(problem.classrooms))), list(range(len(problem.teachers))) ] solver.assignment = solver.binary_var_matrix(*assignment_problem[:2], name="learner_module") solver.module_resources = solver.binary_var_cube(*assignment_problem[1:], name="module_resources")
def _gain(route1: Route, idx1: int, route2: Route, idx2: int) -> float: next1 = DEPOT if idx1 == len(route1) - 1 else route1.customers[idx1 + 1] next2 = DEPOT if idx2 == len(route2) - 1 else route2.customers[idx2 + 1] problem = Problem() customer1 = route1.customers[idx1] customer2 = route2.customers[idx2] # Proposed changes. gain = problem.distances[customer2 + 1, next1 + 1] gain += problem.distances[customer1 + 1, next2 + 1] # Current situation. gain -= problem.distances[customer1 + 1, next1 + 1] gain -= problem.distances[customer2 + 1, next2 + 1] return gain
def main(): if len(sys.argv) < 2: raise ValueError(f"{sys.argv[0]}: expected file location.") problem = Problem.from_file(sys.argv[1], delimiter=',') solution = Solution.from_file(f"solutions/oracs_{problem.instance}.csv") is_feasible = True for idx, rule in enumerate(RULES): result, message = rule(solution) print(f"{idx}: {message}") if not result: is_feasible = False exit(0 if is_feasible else 1)
def random_selection(generator: Generator): """ Implements a random selection mechanism, which selects random indices for a certain list of num_learners length (e.g., for a cost computation), favouring smaller indices. """ problem = Problem() triangle = np.arange(learners_to_remove(), 0, -1) probabilities = np.ones(problem.num_learners) probabilities[:learners_to_remove()] = triangle probabilities = probabilities / np.sum(probabilities) return generator.choice(problem.num_learners, learners_to_remove(), replace=False, p=probabilities)
def random_customers(current: Solution, rnd_state: Generator) -> Solution: """ Removes a number of randomly selected customers from the passed-in solution. See ``customers_to_remove`` for the degree of destruction done. Random removal in Hornstra et al. (2020). """ destroyed = deepcopy(current) for customer in rnd_state.choice(Problem().num_customers, customers_to_remove(), replace=False): destroyed.unassigned.append(customer) route = destroyed.find_route(customer) route.remove_customer(customer) return destroyed
def module_classroom_room_type(solution: List[Tuple]) -> bool: """ Verifies each classroom-module assignment satisfies the room type requirement. """ problem = Problem() classrooms = {} for assignment in solution: _, module, classroom, _ = assignment classrooms[classroom] = module for classroom_idx, module_idx in classrooms.items(): classroom = problem.classrooms[classroom_idx] module = problem.modules[module_idx] if not classroom.is_qualified_for(module): return False return True
def strictly_positive_assignment(solver): """ Ensures learners are assigned only to modules for which they hold a strictly positive preference. This guarantees learners are not assigned to modules they are currently ineligible to take. """ problem = Problem() for learner, module in itertools.product(range(len(problem.learners)), range(len(problem.modules))): preference = problem.preferences[learner, module] \ * solver.assignment[learner, module] grace = solver.B * (1 - solver.assignment[learner, module]) # For each learner and module assignment, the preference for that # module needs to be strictly positive - unless the learner is not # assigned, in which case we use a grace term to ensure the constraint # holds. solver.add_constraint(preference + grace >= 0.00001)
def teacher_module_qualifications(solution: List[Tuple]) -> bool: """ Verifies each teacher-module assignment satisfies the required teacher qualification. """ problem = Problem() teacher_modules = {} for assignment in solution: _, module, _, teacher = assignment teacher_modules[teacher] = module for teacher_idx, module_idx in teacher_modules.items(): teacher = problem.teachers[teacher_idx] module = problem.modules[module_idx] if not teacher.is_qualified_for(module): return False return True
def self_study_allowed(solver): """ For each classroom, checks if a self-study assignment is allowed. This ensures only classrooms that allow self-study are used for self-study assignments. Note ---- Multiple room types allow self-study, so self-study has its own boolean flag to differentiate. """ problem = Problem() for classroom in range(len(problem.classrooms)): assignment = solver.sum(solver.module_resources[len(problem.modules) - 1, classroom, teacher] for teacher in range(len(problem.teachers))) is_allowed = problem.classrooms[classroom].is_self_study_allowed() solver.add_constraint(assignment <= is_allowed)
def all_pickups_are_satisfied(solution: Solution) -> Tuple[bool, str]: """ Verifies all pickups are satisfied, that is, the pickup items are loaded according to a feasible loading plan for each customer. """ problem = Problem() for customer in range(problem.num_customers): route = solution.find_route(customer) pickup = problem.pickups[customer] for stacks in route.plan[route.customers.index(customer) + 1:]: try: # Quickly finds the stack this item is stored in, or raises # if no such stack exists. Just the existence is sufficient. stacks.find_stack(pickup) except LookupError: return False, f"{pickup} is not in the solution for all " \ f"appropriate legs of the route." return True, "All pick-ups are satisfied."
def teaching_qualification(solver): """ The teaching qualification constraint ensures teacher-module assignments are feasible. Each module has a teaching qualification requirement, and each teacher has a qualification for each module. Qualifications are ordinal as {0, 1, 2, 3}, where ``0`` indicates no qualification, and the rest are in decreasing level of qualification: a ``1`` is better than a ``2``. Example ------- Suppose a module requires a qualification of ``2``. Then any teacher with an equal or better (in this case, ``2`` or ``1``) qualification is eligible to teach this activity. Note ---- Since self-study has a required qualification of ``3``, every teacher is eligible to supervise the activity. As such, we can ignore this assignment here. """ problem = Problem() for module, teacher in itertools.product(range(len(problem.modules) - 1), range(len(problem.teachers))): is_assigned = solver.sum( solver.module_resources[module, classroom, teacher] for classroom in range(len(problem.classrooms))) module_qualification = problem.modules[module].qualification teacher_qualification = problem.qualifications[teacher, module] # Module requirement must be 'less' than teacher qualification. solver.add_constraint(module_qualification * is_assigned >= teacher_qualification * is_assigned) # Zero implies the teacher is not qualified, so we should ensure those # assignments cannot happen. solver.add_constraint(teacher_qualification * is_assigned >= is_assigned)
def minimum_quantity(current: Solution, rnd_state: Generator) -> Solution: """ Removes customers based on quantity (demand + pickup). Randomly selects q customers based on a distribution over these quantities, favouring smaller over larger quantity customers. Similar - but not equivalent - to minimum quantity removal in Hornstra et al. (2020). """ problem = Problem() destroyed = deepcopy(current) indices = random_selection(rnd_state) customers = problem.smallest_quantity_customers[indices] for customer in customers: destroyed.unassigned.append(customer) route = destroyed.find_route(customer) route.remove_customer(customer) return destroyed
def activity_size(solution: List[Tuple]) -> bool: """ Verifies each activity satisfies both the minimum and maximum group size constraints. """ problem = Problem() classroom_learners = defaultdict(set) for assignment in solution: learner, module, classroom, _ = assignment classroom_learners[classroom, module].add(learner) for (classroom, module), learners in classroom_learners.items(): max_capacity = problem.classrooms[classroom].capacity if module != SELF_STUDY_MODULE_ID: max_capacity = min(problem.max_batch, max_capacity) if not problem.min_batch <= len(learners) <= max_capacity: return False return True
def _near_best_insert(nearness: int, current: Solution, rnd_state: Generator) -> Solution: """ Sequentially inserts a random permutation of the unassigned customers into a near-best feasible route. The distance is controlled by the nearness parameter: the feasible route is within nearness steps from the best insertion point. Note: nearness == 1 implies a full greedy insert. """ rnd_state.shuffle(current.unassigned) problem = Problem() while len(current.unassigned) != 0: customer = current.unassigned.pop() feasible_routes = Heap() for route in current.routes: insert_idx, cost = route.opt_insert(customer) if route.can_insert(customer, insert_idx): feasible_routes.push(cost, (insert_idx, route)) if len(feasible_routes) != 0: num_smallest = min(nearness, len(feasible_routes)) routes = feasible_routes.nsmallest(num_smallest) cost, (insert_idx, route) = routes[rnd_state.choice(num_smallest)] cost_new = problem.short_distances[DEPOT, customer, DEPOT] if cost_new > cost: route.insert_customer(customer, insert_idx) continue route = create_single_customer_route(customer) current.routes.append(route) return current
def handling_costs(solution: Solution) -> np.ndarray: """ Computes handling costs for each customer. This is an approximation: only the handling costs *at* the customer are computed, any costs made at other legs of the tour are not considered. O(|customers|). Note: this is a lower bound on the actual handling costs, as those are rather hard to compute. Some experimentation suggests it is e.g. not worthwhile to also count the costs incurred due to the delivery and pickup items at other legs of the route. """ problem = Problem() costs = np.zeros(problem.num_customers) for route in solution.routes: for idx, customer in enumerate(route): before, after = route.plan[idx], route.plan[idx + 1] # This is the handling cost for just this customer, as an # approximation to the total handling costs. costs[customer] += Stacks.cost(customer, before, after) return costs
def singular_use(solver): """ Ensures each classroom and each teacher are used exactly once. """ problem = Problem() for teacher in range(len(problem.teachers)): classrooms = solver.sum( solver.module_resources[module, classroom, teacher] for module in range(len(problem.modules)) for classroom in range(len(problem.classrooms))) # Each teacher may be assigned to *at most* one classroom. solver.add_constraint(classrooms <= 1) for classroom in range(len(problem.classrooms)): teachers = solver.sum(solver.module_resources[module, classroom, teacher] for module in range(len(problem.modules)) for teacher in range(len(problem.teachers))) # Each classroom may be assigned to *at most* one teacher. solver.add_constraint(teachers <= 1)
def initial_solution() -> Solution: """ Constructs an initial solution, where all learners are in self-study activities, in appropriate classrooms (and with some random teacher assigned to supervise). """ problem = Problem() solution = Solution([]) # Not all classrooms are suitable for self-study. Such a restriction does, # however, not apply to teachers. classrooms = [classroom for classroom in problem.classrooms if classroom.is_self_study_allowed()] learners_to_assign = problem.learners for classroom, teacher in zip_longest(classrooms, problem.teachers): assert classroom is not None assert teacher is not None learners = learners_to_assign[-min(len(learners_to_assign), classroom.capacity):] activity = Activity(learners, classroom, teacher, problem.self_study_module) solution.add_activity(activity) learners_to_assign = learners_to_assign[:-activity.num_learners] if len(learners_to_assign) == 0: break return solution
def max_batch(solver): """ This constraints guarantees the number of learners assigned to a module are supported by a sufficient number of classroom-teacher activities, such that the maximum batch and capacity constraints are satisfied. Note ---- The ``max_batch`` constraint only holds for regular instruction activities, for self-study the constraint defaults to a capacity requirement. """ problem = Problem() for module in problem.modules: module_learners = solver.sum(solver.assignment[learner.id, module.id] for learner in problem.learners) activities = solver.sum( solver.module_resources[module.id, classroom.id, teacher.id] * _max_capacity(classroom, module) for classroom in problem.classrooms for teacher in problem.teachers) solver.add_constraint(module_learners <= activities)
def customers(solution: Solution) -> int: """ Returns the number of customers in the problem instance. """ return Problem().num_customers
def handling(solution: Solution) -> float: """ Returns the problem's handling cost parameter. """ return Problem().handling_cost
def instance(_): return Problem().instance
def stacks(solution: Solution) -> int: """ Returns the number of stacks in the problem instance's vehicles. """ return Problem().num_stacks
def instance(solution: Solution) -> int: """ Returns problem instance number. """ return Problem().instance
def greedy_insert(destroyed: Solution, generator: Generator) -> Solution: """ Greedily inserts learners into the best, feasible activities. If no activity can be found for a learner, (s)he is inserted into self-study instead. """ problem = Problem() unused_teachers = set(problem.teachers) - destroyed.used_teachers() unused_classrooms = set(problem.classrooms) - destroyed.used_classrooms() unused_classrooms = [ classroom for classroom in unused_classrooms if classroom.is_self_study_allowed() ] # It is typically a good idea to prefers using larger rooms for self-study. unused_classrooms.sort(key=attrgetter("capacity")) activities = destroyed.activities_by_module() while len(destroyed.unassigned) != 0: learner = destroyed.unassigned.pop() inserted = False # Attempts to insert the learner into the most preferred, feasible # instruction activity. for module_id in problem.most_preferred[learner.id]: module = problem.modules[module_id] if module not in activities: continue if not learner.prefers_over_self_study(module): break # TODO Py3.8: use assignment expression in if-statement. inserted = _insert(learner, activities[module]) if inserted: break # Could not insert, so the module activities must be exhausted. del activities[module] # Learner could not be inserted into a regular instruction activity, # so now we opt for self-study. if not inserted and not _insert(learner, activities[problem.self_study_module]): if len(unused_classrooms) == 0 or len(unused_teachers) == 0: # This implies we need to remove one or more instruction # activities. Let's do the naive and greedy thing, and switch # the instruction activity with lowest objective value into a # self-study assignment. iterable = [ activity for activity in destroyed.activities if activity.is_instruction() if activity.classroom.is_self_study_allowed() # After switching to self-study, the max_batch # constraint is longer applicable - only capacity. if activity.num_learners < activity.classroom.capacity ] activity = min(iterable, key=methodcaller("objective")) activities[problem.self_study_module].append(activity) activities[activity.module].remove(activity) activity.switch_to_self_study() activity.insert_learner(learner) continue for activity in activities[problem.self_study_module]: biggest_classroom = unused_classrooms[-1] if activity.classroom.capacity < biggest_classroom.capacity: current = activity.classroom activity.classroom = unused_classrooms.pop() unused_classrooms.insert(0, current) destroyed.switch_classrooms(current, activity.classroom) activity.insert_learner(learner) break if activity.can_split(): teacher = unused_teachers.pop() classroom = unused_classrooms.pop() new_activity = activity.split_with(classroom, teacher) activity.insert_learner(learner) destroyed.add_activity(new_activity) activities[problem.self_study_module].append(new_activity) break else: # It could be that there is no self-study activity. In that # case we should make one. Should be rare. classroom = unused_classrooms.pop() teacher = unused_teachers.pop() # TODO what if there are insufficient learners left? learners = [ destroyed.unassigned.pop() for _ in range(problem.min_batch) ] activity = Activity(learners, classroom, teacher, problem.self_study_module) # Since we popped this learner from the unassigned list before, # it is not yet in the new activity. activity.insert_learner(learner) destroyed.add_activity(activity) activities[problem.self_study_module].append(activity) return destroyed
def learners_to_remove() -> int: problem = Problem() return int(DEGREE_OF_DESTRUCTION * problem.num_learners)
def _to_assignments(solver: Model) -> List[Tuple]: """ Turns the solver's decision variables into a series of (learner, module, classroom, teacher) assignments, which are then stored to the file system. TODO this is legacy code, taken from the old State object. It's not the prettiest, but it should work. """ problem = Problem() learner_assignments = [ module for learner in range(len(problem.learners)) for module in range(len(problem.modules)) if solver.assignment[learner, module].solution_value ] classroom_teacher_assignments = { (classroom, teacher): module for classroom in range(len(problem.classrooms)) for teacher in range(len(problem.teachers)) for module in range(len(problem.modules)) if solver.module_resources[module, classroom, teacher].solution_value } assignments = [] counters = defaultdict(lambda: 0) for module in range(len(problem.modules)): # Select learners and activities belonging to each module, such # that we can assign them below. learners = [ learner for learner in range(len(problem.learners)) if learner_assignments[learner] == module ] activities = [ activity for activity, activity_module in classroom_teacher_assignments.items() if module == activity_module ] # Assign at least min_batch number of learners to each activity. # This ensures the minimum constraint is met for all activities. for classroom, teacher in activities: for _ in range(problem.min_batch): if not learners: break assignment = (learners.pop(), module, classroom, teacher) assignments.append(list(assignment)) counters[classroom] += 1 # Next we flood-fill these activities with learners, until none # remain to be assigned. for classroom, teacher in activities: capacity = problem.classrooms[classroom].capacity if module != SELF_STUDY_MODULE_ID: capacity = min(problem.max_batch, capacity) while learners: if counters[classroom] == capacity: # classroom is full break assignment = (learners.pop(), module, classroom, teacher) assignments.append(list(assignment)) counters[classroom] += 1 return assignments
def customers_to_remove() -> int: """ Returns the number of customers to remove from the solution. """ return int(Problem().num_customers * DEGREE_OF_DESTRUCTION)