def _refine_solution(sol, D, d, C, L, minimize_K): # refine until stuck at a local optima local_optima_reached = False while not local_optima_reached: sol = without_empty_routes(sol) if not minimize_K: sol.append(0) #make sure there is an empty route to move the pt to # improve with relocation and keep 2-optimal sol = do_local_search([do_1point_move, do_2opt_move], sol, D, d, C, L, LSOPT.BEST_ACCEPT) # try to redistribute the route with smallest demand sol = without_empty_routes(sol) routes = RouteData.from_solution(sol, D, d) min_rd = min(routes, key=lambda rd: rd.demand) routes.remove(min_rd) if not minimize_K: routes.append(RouteData()) if __debug__: log( DEBUG, "Applying do_redistribute_move on %s (%.2f)" % (str(sol), objf(sol, D))) redisribute_result = do_redistribute_move( min_rd, routes, D, d, C, L, strategy=LSOPT.FIRST_ACCEPT, #Note: Mole and Jameson do not specify exactly # how the redistribution is done (how many # different combinations are tried). # Increase the recombination_level if the # for more agressive and time consuming search # for redistributing the customers on other # routes. recombination_level=0) redisribute_delta = redisribute_result[-1] if (redisribute_delta is not None) and\ (minimize_K or redisribute_delta<0.0): updated_sol = RouteData.to_solution(redisribute_result[:-1]) if __debug__: log(DEBUG - 1, ("Improved from %s (%.2f) to %s (%.2f)" % (sol, objf(sol, D), updated_sol, objf(updated_sol, D))) + "using inter route heuristic do_redistribute_move\n") sol = updated_sol else: local_optima_reached = True if __debug__: log(DEBUG - 1, "No move with do_redistribute_move\n") return sol
def wren_holliday_init(points, D, d, C, L=None, minimize_K=False, seed_node=BEST_OF_FOUR, direction='both', full_convergence=True): """ This implements the Wren and Holliday improvement heuristic. The initial solution is generated using the generic sweep procedure of `sweep.py`, and the improvement procedure works as specified in Wren & Holliday (1972) Fig 1 (Flowchart of program). Basically, the refining processes, also known as local search improvement / post-optimization phase, repeadedly applies local search operators to improve the initial solutions and returns the best one. * D is a numpy ndarray (or equvalent) of the full 2D distance matrix. * d is a list of demands. d[0] should be 0.0 as it is the depot. * C is the capacity constraint limit for the identical vehicles. * L is the optional constraint for the maximum route cost/duration/length. * seed_node sets how many different seed nodes are tried for the Sweep based initial solution generation. If LEAST_DENSE, the sweep is started from the direction from the depot that has lowest customer density. The same applies to BEST_OF_FOUR (default), but also 3 other directions spaced by ~90deg are considered. Also a complete (but costly) generation of all possible initial solutions with BEST_ALTERNATIVE can be used. * direction can be 'ccw' for counter-clockwise Sweep, 'cw' (default) for clockwise or 'both' for trying both. * full_convergence determines if the improvement is stopped after the "delete" operation fails the first time (False) or if the local search continues until no operation is capable of finding an improving more (True, default). Returns the solution. Wren, A., & Holliday, A. (1972). Computer scheduling of vehicles from one or more depots to a number of delivery points. Journal of the Operational Research Society, 23(3), 333-344. """ if not points: raise ValueError( "The algorithm requires 2D coordinates for the points") N = len(D) # Calculate the sweep coordinates # this is 99% same as _get_sweep..., but we need to get also # the node_phis for later use, so duplicated some code here. np_pts = points if isinstance(points, np.ndarray) else np.asarray(points) depot_x, depot_y = points[0] node_rhos, node_phis = cart2pol(np_pts[:, 0] - depot_x, np_pts[:, 1] - depot_y) sweep = get_sweep_from_polar_coordinates(node_rhos, node_phis) sweep_phis = sweep[0] directions = ['cw', 'ccw'] if direction == 'both' else [direction] sweeps = [] for cur_dir in directions: if seed_node == BEST_OF_FOUR or seed_node == LEAST_DENSE: # Wren & Holliday method of selecting starting customers for the # Sweep initialization from the "least dense direction". # Turn the polar coordinates so that the smallest angles are to the # direction of the smallest density, weighted by their demands. o = np.array([depot_x, depot_y]) if d: weighted_xy_from_origin = np.multiply( np_pts[1:] - o, np.transpose(np.array([d[1:], d[1:]]))) avgx, avgy = np.average(weighted_xy_from_origin, axis=0) else: avgx, avgy = np.average(np_pts[1:] - o, axis=0) avg_rho, avg_phi = cart2pol(np.array([avgx]), np.array([avgy])) # to range [-pi, pi] if avg_phi > 0: least_dense_phi = avg_phi[0] - pi else: least_dense_phi = avg_phi[0] + pi # evenly spaced by pi/2 for i in range(4): angle_tgt = least_dense_phi + i * pi / 2 if angle_tgt > pi: angle_tgt -= pi * 2 # take the first satisfying the condition start_from_here = np.argmax(sweep_phis > angle_tgt) start_node = start_from_here-1 if cur_dir=="cw" \ else start_from_here if start_node == -1: start_node = N - 2 sweeps.append((start_node, cur_dir)) if seed_node == LEAST_DENSE: break # do not take the ones at 90 deg intervals elif seed_node == BEST_ALTERNATIVE: nodes = list(range(N - 1)) sweeps.extend(zip(nodes, [cur_dir] * len(nodes))) elif type(seed_node) == int: sweeps.append([seed_node, cur_dir]) ## PHASE 1 : "Generate ... initial solutions and choose the best" initial_sols = [] try: for start_node, cur_dir in sweeps: isol = sweep_init(sweep, D, d, C, L, seed_node=[start_node], direction=cur_dir, routing_algo=None) initial_sols.append(isol) except KeyboardInterrupt as e: # or SIGINT # if interrupted on initial sol gen, return the best of those if len(e.args) > 0 and type(e.args[0]) is list: initial_sols.append(e.args[0]) if not initial_sols: raise e else: best_isol, best_if, best_iK = None, float('inf'), float('inf') for isol in initial_sols: isol_f = objf(isol, D) isol_K = isol.count(0) - 1 if is_better_sol(best_if, best_iK, isol_f, isol_K, minimize_K): best_isol = isol best_if = isol_f best_iK = isol_K raise KeyboardInterrupt(best_isol) best_sol, best_f, best_K = None, float('inf'), float('inf') interrupted = False for sol in initial_sols: # Construct an array of RouteData objects for local search improvement # heuristics to use routes = RouteData.from_solution(sol, D, d) if __debug__: log(DEBUG, "Improving solution %s (%.2f)" % (sol, objf(sol, D))) _log_after_ls_op("SWEEP(S)", False, routes, D) ## PHASE 2 : Improvement phase (see Wren & Holliday 1974, Figure 1) # Variables storing the state converging = False deleted = True omitted_nodes = set() prev_Q_point_sol_f = None prev_iteration_sol_f = None changed = False try: while True: _remove_empty_in_place(routes) if not minimize_K: # +1 empty route can allow local search to find an improvement routes.append(RouteData()) changed = False ## "INSPECT, SINGLE" ## # run 2opt on each route to remove any crossing edges inspect_improved = inspect_heuristic(routes, D, C, d, L) if inspect_improved and minimize_K: _remove_empty_in_place(routes) changed |= inspect_improved if __debug__: _log_after_ls_op("INSPECT", changed, routes, D) # move a node to a better position on the route or other routes single_improved = single_heuristic(routes, D, C, d, L) if single_improved and minimize_K: _remove_empty_in_place(routes) changed |= single_improved if __debug__: _log_after_ls_op("SINGLE", changed, routes, D) ## "Are customers omitted?" ## omitted_were_assigned = False if omitted_nodes: inserted_nodes = set() ## "Take omitted ... in order and try to fit into existing routes" ## for node in sorted(list(omitted_nodes)): for rdi, rd in enumerate(routes): _, new_rd, delta = do_insert_move( node, rd, D, d, C, L, LSOPT.BEST_ACCEPT) if delta is not None: routes[rdi] = new_rd inserted_nodes.add(node) omitted_were_assigned = True omitted_nodes -= inserted_nodes if omitted_were_assigned and minimize_K: _remove_empty_in_place(routes) changed |= omitted_were_assigned if __debug__: _log_after_ls_op("INSERT", changed, routes, D) ## "Are customers omitted still?" ## if omitted_nodes: omitted_were_assigned |= complain_heuristic( routes, omitted_nodes, D, C, d, L) if omitted_were_assigned and minimize_K: _remove_empty_in_place(routes) changed |= omitted_were_assigned if __debug__: _log_after_ls_op("COMPLAIN", changed, routes, D) sol_f = 0 for rd in routes: sol_f += rd.cost ## Q-point : "Has distance been reduced by more that 5% OR # has a previously omitted customer been assigned?" ## if (prev_Q_point_sol_f is None) or\ (sol_f<prev_Q_point_sol_f*0.95) or \ omitted_were_assigned: prev_Q_point_sol_f = sol_f converging = False continue else: prev_Q_point_sol_f = sol_f converging = True ## "Is problem small?" -> PAIR ## if len(D) <= 80: pair_improved = pair_heuristic(routes, D, C, d, L) if pair_improved and minimize_K: _remove_empty_in_place(routes) changed |= pair_improved if __debug__: _log_after_ls_op("PAIR", changed, routes, D) ## "Is deleted true?" ## if deleted: # "DELETE" -> "Was delete succesful?" ## deleted = delete_heuristic(routes, D, C, d, L) if deleted and minimize_K: _remove_empty_in_place(routes) changed |= deleted if __debug__: _log_after_ls_op("DELETE", changed, routes, D) ## DISENTANGLE ## disentangle_improved = disentangle_heuristic( routes, sweep, node_phis, D, C, d, L) if disentangle_improved and minimize_K: _remove_empty_in_place(routes) changed |= disentangle_improved if __debug__: _log_after_ls_op("DISENTANGLE", changed, routes, D) ## "Has situation changed in interation?" ## solution_changed_between_iterations = True if prev_iteration_sol_f: if prev_iteration_sol_f == sol_f: solution_changed_between_iterations = False prev_iteration_sol_f = sol_f if converging and ((full_convergence and not changed) or (not full_convergence and not deleted) or (not solution_changed_between_iterations)): ## STOP ## break except KeyboardInterrupt: interrupted = True # return the optimized solutios sol = [0] + [n for r in routes for n in r.route[1:]] # LS may cause empty routes sol = without_empty_routes(sol) sol_K = sol.count(0) - 1 sol_f = objf(sol, D) if __debug__: log(DEBUG, "Improved solution %s (%.2f)" % (sol, sol_f)) if is_better_sol(best_f, best_K, sol_f, sol_K, minimize_K): best_sol = sol best_f = sol_f best_K = sol_K if interrupted: raise KeyboardInterrupt(best_sol) return best_sol
def disentangle_heuristic(routes, sweep, node_phis, D, C, d, L): # find a overlapping / "tanged" pair improvement_found = False entangled = True route_phi_ranges = [] for rd in routes: route = rd.route if rd.is_empty(): route_phi_ranges.append(None) else: r_phis = node_phis[route[1:-1]] r_phi_range = _get_route_phi_range(r_phis) route_phi_ranges.append(r_phi_range) while (entangled): entangled = False for rd1_idx, rd2_idx in permutations(range(len(routes)), 2): # unpack route, current cost, and current demand route1, r1l, r1d, _ = routes[rd1_idx] route2, r2l, r2d, _ = routes[rd2_idx] r1_phi_range = route_phi_ranges[rd1_idx] r2_phi_range = route_phi_ranges[rd2_idx] # the route may have become empty if (r1_phi_range is None) or (r2_phi_range is None): continue # ranges overlap overlap = ((r1_phi_range[0][0] < r2_phi_range[0][1] and r2_phi_range[0][0] < r1_phi_range[0][1]) or\ (r1_phi_range[1][0] < r2_phi_range[1][1] and\ r2_phi_range[1][0] < r1_phi_range[1][1])) if overlap: et_nodes = route1[:-1] + route2[1:-1] et_nodes.sort() # Check for backwards compatibility: numpy.isin was introduced # in 1.13.0. If it not availabe (as is the case e.g. in Ubuntu # 16.04 LTS), use the equivalent list comprehension instead. if hasattr(np, 'isin'): sweep_mask = np.isin(sweep[2], et_nodes, assume_unique=True) else: sweep_mask = np.array( [item in et_nodes for item in sweep[2]]) et_sweep = sweep[:, sweep_mask] over_wrap_phi = (et_sweep[0][0] + 2 * pi) - et_sweep[0][-1] et_seed_node = [ np.argmax(np.ediff1d(et_sweep[0], to_end=over_wrap_phi)) ] reconstructed_et_sol = sweep_init(et_sweep, D, d, C, L, seed_node=et_seed_node, direction="cw", routing_algo=None) # translate nodes back to master problem node indices #reconstructed_et_sol = [et_nodes[n] for n in reconstructed_et_sol] # construct an array of RouteData objects for heuristics et_routes = RouteData.from_solution(reconstructed_et_sol, D, d) omitted = set() for extra_route in et_routes[2:]: omitted |= set(extra_route.route[1:-1]) et_routes = et_routes[:2] if __debug__: log(DEBUG - 1, "DISENTANGLE omitted %s" % str(list(omitted))) _log_after_ls_op("DISENTANGLE/SWEEP", True, et_routes, D) refined = True while refined: refined = False refined |= inspect_heuristic(et_routes, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/INSPECT", refined, et_routes, D) refined |= single_heuristic(et_routes, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/SINGLE", refined, et_routes, D) # there were omitted nodes, try to complain them in if omitted: refined |= complain_heuristic(et_routes, omitted, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/COMPLAIN", refined, et_routes, D) # all nodes routed and disentangling improves -> accept this if len(et_routes) == 1: # keep an empty route (for now) to avoid indexing errors et_routes.append(RouteData([0, 0], 0.0, 0.0)) disentange_improves = True else: disentange_improves = et_routes[0].cost + et_routes[ 1].cost + S_EPS < r1l + r2l if len(omitted) == 0 and disentange_improves: routes[rd1_idx] = et_routes[0] routes[rd2_idx] = et_routes[1] # The routes were updated, update also the ranges r1_phis = node_phis[et_routes[0].route[1:-1]] r2_phis = node_phis[et_routes[1].route[1:-1]] r1_phi_range = _get_route_phi_range(r1_phis) r2_phi_range = _get_route_phi_range(r2_phis) route_phi_ranges[rd1_idx] = r1_phi_range route_phi_ranges[rd2_idx] = r2_phi_range if __debug__: if len(omitted) > 0: log( DEBUG - 1, "DISENTANGLE rejected ( due to omitted %s)" % str(omitted)) elif disentange_improves: log( DEBUG - 1, "DISENTANGLE rejected ( due to not improving %.2f vs. %.2f )" % (et_routes[0].cost + et_routes[1].cost, r1l + r2l)) else: log( DEBUG - 1, "DISENTANGLE accepted ( due to inserting omitted and improving %.2f vs. %.2f )" % (et_routes[0].cost + et_routes[1].cost, r1l + r2l)) return improvement_found
def do_local_search(ls_ops, sol, D, d, C, L=None, operator_strategy=LSOPT.FIRST_ACCEPT, iteration_strategy=ITEROPT.ALL_ACCEPT, max_iterations=None): """ Repeatedly apply ls_ops until no more improvements can be made. The procedure keeps track of the changed routes and searches only combinations that have been changed. Optionally the operator_strategy FIRST_ACCEPT (default)/BEST_ACCEPT can be given as well as the maximum number of iterations (that is, how many times all given operations are applied until giving up on reaching local optima). The iteration_strategy has an effect on which order the operations are applied. If ALL_ACCEPT (default), each operator is applied in turn until no improving moves are found. The options are: * FIRST_ACCEPT accept every improving move returned by the operator, and start again from the first operator. * BEST_ACCEPT accept the very best (single) move over all operators. * ALL_ACCEPT accept every improving move of each operator and continue. * REPEATED_ACCEPT run operator until no improving moves are found before moving on to the next operator. Note that these may freely be combined with the operator_strategy. """ current_sol = sol route_datas = RouteData.from_solution(sol, D, d) route_data_idxs = list(range(len(route_datas))) # We keep track of the operations to avoid search when it has already been # unsuccesfully applied at_lsop_optimal = defaultdict(set) customer_to_at_lsopt_optimal = defaultdict(list) iteration = 0 improving_iteration = True while improving_iteration: improving_iteration = False best_iteration_result = None best_iteration_delta = None best_iteration_operator = None ls_op_idx = 0 while ls_op_idx<len(ls_ops): ls_op = ls_ops[ls_op_idx] ls_op_args = getargspec(ls_op)[0] route_count = ls_op_args.index('D') op_order_sensitive = ls_op in ROUTE_ORDER_SENSITIVE_OPERATORS op_improved = False if __debug__: log(DEBUG-1, "Applying %s on %s"%(ls_op.__name__, str(current_sol))) # TODO: Consider using a counter to check for this # check if we already reached local optima on all routes with ls_op #if all( (ls_op in lsop_optimal[ri]) for ri in route_data_idxs ): # if __debug__: # log(DEBUG-2, "All route combinations already searched for %s, skipping it."%ls_op.__name__) # break best_delta = None best_result = None no_improving_lsop_found = set() for route_indices in permutations(route_data_idxs,route_count): # If the order does not matter, require that the route indices # are ordered from smallest to largest. if (not op_order_sensitive) and (not is_sorted(route_indices)): continue # ls_op is already at local optima with this combination of routes if ls_op in at_lsop_optimal[route_indices]: if __debug__: log(DEBUG-2, "Route combination %s already searched for %s, skipping it."% (str(route_indices), ls_op.__name__)) continue # The one route case has different call signature if route_count==1: op_params = [route_datas[route_indices[0]].route, D, operator_strategy] else: op_params = [route_datas[ri] for ri in route_indices]+\ [D, d, C, L, operator_strategy] # Ideally, best_delta can be used as an upper # bound to avoid unnecessary result generation # and to allow early ls_op termination. # However, then we lose the ability to mark # some route combinations as ls_optimal. #+[best_delta] result = ls_op(*op_params) #print("REMOVEME:",route_datas[route_indices[0]].route, "->", result) # route was changed, record the change in route datas delta = result[-1] if delta is None: no_improving_lsop_found.update((route_indices,)) else: # For route_count==1 every route contributes for the same # best_delta (unless trying to find the very best *single* # move!) if route_count==1: skip_result = ( (best_delta != None and delta+S_EPS>best_delta) and (iteration_strategy==ITEROPT.BEST_ACCEPT) ) if not skip_result: if ((best_result is None) or (iteration_strategy==ITEROPT.BEST_ACCEPT)): best_result = [] best_delta = 0 old_rd = route_datas[route_indices[0]] new_rd = RouteData(result[0],old_rd.cost+delta,old_rd.demand) best_result.append( (route_indices[0], new_rd) ) best_delta+=delta else: if (best_result is None) or (delta+S_EPS<best_delta): best_result = zip(route_indices, result[:-1]) best_delta = delta # Found a first improving with this operator, move on. if operator_strategy==LSOPT.FIRST_ACCEPT: break # route combination loop # end route combination loop # Mark the routes that had no potential improvements to be at # local optima to avoid checking the same moves again. for ris in no_improving_lsop_found: at_lsop_optimal[ris].add(ls_op) for ri in ris: customer_to_at_lsopt_optimal[ri].append(ris) if best_result is not None: if iteration_strategy==ITEROPT.BEST_ACCEPT: if (best_iteration_result is None) or \ (best_delta+S_EPS<best_iteration_delta): best_iteration_result = best_result best_iteration_delta = best_delta best_iteration_operator = ls_op.__name__ else: op_improved = True improving_iteration = True for ri, new_rd in best_result: route_datas[ri] = new_rd # The route was modified, allow other operators to # check if it can be improved again. for ris in customer_to_at_lsopt_optimal[ri]: at_lsop_optimal[ris].clear() # Check if route is [0,0] or [0] or [] if len(new_rd.route)<=2: # remove this route from the future search route_data_idxs.remove(ri) if __debug__: op_improved = True opt_sol = RouteData.to_solution(route_datas) log(DEBUG, "Improved from %s (%.2f) to %s (%.2f) using %s"% (str(current_sol),objf(current_sol,D),str(opt_sol),objf(opt_sol,D),ls_op.__name__)) current_sol = opt_sol if iteration_strategy==ITEROPT.FIRST_ACCEPT: ls_op_idx = 0 break # the ls_op loop (start from the beginning) if __debug__: if best_result is None: log(DEBUG-1, "No improving move with %s"%ls_op.__name__) if op_improved and iteration_strategy==ITEROPT.FIRST_ACCEPT: # after an improvement start from the first operator ls_op_idx = 0 if op_improved and iteration_strategy==ITEROPT.REPEATED_ACCEPT: # keep repeating the operator until no improvement is found ls_op_idx = ls_op_idx else: # BEST_ACCEPT and ALL_ACCEPT always move on ls_op_idx += 1 #END OF LS_OP LOOP if (iteration_strategy==ITEROPT.BEST_ACCEPT) and\ (best_iteration_result is not None): improving_iteration = True for ri, new_rd in best_iteration_result: route_datas[ri] = new_rd # The route was modified, allow other operators to # check if it can be improved again. for ris in customer_to_at_lsopt_optimal[ri]: at_lsop_optimal[ris].clear() # Check if route is [0,0] or [0] or [] if len(new_rd.route)<=2: # remove this route from the future search route_data_idxs.remove(ri) if __debug__: op_improved = True opt_sol = RouteData.to_solution(route_datas) log(DEBUG, "Improved from %s (%.2f) to %s (%.2f) using %s"% (str(current_sol),objf(current_sol,D),str(opt_sol),objf(opt_sol,D),best_iteration_operator)) current_sol = opt_sol iteration+=1 if max_iterations and iteration>=max_iterations: break # iteration loop current_sol = RouteData.to_solution(route_datas) if __debug__: log(DEBUG,"Repeadedly applying %s resulted in %s"% (",".join(ls_op.__name__ for ls_op in ls_ops),str(current_sol))) return current_sol