def setup(distance_matrix: np.ndarray, x0: Optional[List] = None) -> Tuple[List[int], float]: """Return initial solution and its objective value Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j x0 Permutation of nodes from 0 to n - 1 indicating the starting solution. If not provided, a random list is created. Returns ------- x0 Permutation with initial solution. If ``x0`` was provided, it is the same list fx0 Objective value of x0 """ if not x0: n = distance_matrix.shape[0] # number of nodes x0 = [0] + sample(range(1, n), n - 1) # ensure 0 is the first node fx0 = compute_permutation_distance(distance_matrix, x0) return x0, fx0
def initial_temperature(distance_matrix: np.ndarray, x: List[int], fx: float, perturbation_scheme: str = "ps3") -> float: """Compute initial temperature Instead of relying on problem-dependent parameters, this function estimates the temperature using the suggestion in [1]. Notes ----- Here are the steps followed: 1. Generate 100 disturbances at random from T0, and evaluate the mean objective value differences dfx_mean = mean(fn - fx); 2. Choose tau0 = 0.5 as assumed quality of initial solution (assuming a bad one), and deduce T0 from exp(-fx_mean/T0) = tau0, that is, T0 = -fx_mean/ln(tau0) References ---------- [1] Dréo, Johann, et al. Metaheuristics for hard optimization: methods and case studies. Springer Science & Business Media, 2006. """ # Step 1 dfx_list = [] for _ in range(100): xn = _perturbation(x, perturbation_scheme) fn = compute_permutation_distance(distance_matrix, xn) dfx_list.append(fn - fx) dfx_mean = np.abs(np.mean(dfx_list)) # Step 2 tau0 = 0.5 return -dfx_mean / np.log(tau0)
def test_return_correct_permutation_distance_initial_node_not_zero( self, distance_matrix, expected_distance ): """ Check if the correct distance is returned when the first node is not 0 """ # Same permutation as before but starting at 3 permutation = [3, 1, 4, 0, 2] distance = compute_permutation_distance(distance_matrix, permutation) assert distance == expected_distance
def test_return_correct_permutation_distance( self, distance_matrix, expected_distance ): """ Check if the correct distance is returned for a given permutation and a given distance matrix """ permutation = [0, 2, 3, 1, 4] distance = compute_permutation_distance(distance_matrix, permutation) assert distance == expected_distance
def test_initial_temperature(self, distance_matrix, scheme): """ The initial temperature is mostly random, so simply check if it is valid (greater than zero). """ fx = compute_permutation_distance(distance_matrix, self.x) temp = simulated_annealing._initial_temperature( distance_matrix, self.x, fx, perturbation_scheme=scheme ) assert temp > 0
def test_local_search_returns_better_neighbor(self, scheme, distance_matrix): """ If there is room for improvement, a better neighbor is returned. Here, we choose purposely a permutation that can be improved. """ x = [0, 4, 2, 3, 1] fx = compute_permutation_distance(distance_matrix, x) xopt, fopt = local_search.solve_tsp_local_search( distance_matrix, x, perturbation_scheme=scheme) assert fopt < fx
def solve_tsp_brute_force( distance_matrix: np.ndarray) -> Tuple[Optional[List], Any]: """Solve TSP to optimality with a brute force approach Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j. It does not need to be symmetric Returns ------- permutation A permutation of nodes from 0 to n that produces the least total distance distance The total distance the optimal permutation produces Notes ---- The algorithm checks all permutations and returns the one with smallest distance. In principle, the total number of possibilities would be n! for n nodes. However, we can fix node 0 and permutate only the remaining, reducing the possibilities to (n - 1)!. """ # Exclude 0 from the range since it is fixed as starting point points = range(1, distance_matrix.shape[0]) best_distance = np.inf best_solution = None for partial_permutation in permutations(points): # Remember to add the starting node before evaluating it permutation = [0] + list(partial_permutation) distance = compute_permutation_distance(distance_matrix, permutation) if distance < best_distance: best_distance = distance best_solution = permutation return best_solution, best_distance
def solve_tsp_simulated_annealing( distance_matrix: np.ndarray, x0: Optional[List[int]] = None, perturbation_scheme: str = "two_opt", alpha: float = 0.9, max_processing_time: float = None, log_file: Optional[str] = None, ) -> Tuple[List, float]: """Solve a TSP problem using a Simulated Annealing The approach used here is the one proposed in [1]. Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j x0 Initial permutation. If not provided, it starts with a random path perturbation_scheme {"ps1", "ps2", "ps3", "ps4", "ps5", "ps6", ["two_opt"]} Mechanism used to generate new solutions. Defaults to "two_opt" alpha Reduction factor (``alpha`` < 1) used to reduce the temperature. As a rule of thumb, 0.99 takes longer but may return better solutions, whike 0.9 is faster but may not be as good. A good approach is to use 0.9 (as default) and if required run the returned solution with a local search. max_processing_time {None} Maximum processing time in seconds. If not provided, the method stops only when there were 3 temperature cycles with no improvement. log_file If not `None`, creates a log file with details about the whole execution Returns ------- A permutation of nodes from 0 to n - 1 that produces the least total distance obtained (not necessarily optimal). The total distance the returned permutation produces. References ---------- [1] Dréo, Johann, et al. Metaheuristics for hard optimization: methods and case studies. Springer Science & Business Media, 2006. """ x, fx = setup(distance_matrix, x0) temp = _initial_temperature(distance_matrix, x, fx, perturbation_scheme) max_processing_time = max_processing_time or np.inf if log_file: fh = logging.FileHandler(log_file) fh.setLevel(logging.INFO) logger.addHandler(fh) logger.setLevel(logging.INFO) n = len(x) k_inner_min = n # min inner iterations k_inner_max = 10 * n # max inner iterations k_noimprovements = 0 # number of inner loops without improvement tic = default_timer() stop_early = False while (k_noimprovements < 3) and (not stop_early): k_accepted = 0 # number of accepted perturbations for k in range(k_inner_max): if default_timer() - tic > max_processing_time: logger.warning("Stopping early due to time constraints") stop_early = True break xn = _perturbation(x, perturbation_scheme) fn = compute_permutation_distance(distance_matrix, xn) if _acceptance_rule(fx, fn, temp): x, fx = xn, fn k_accepted += 1 k_noimprovements = 0 logger.info(f"Temperature {temp}. Current value: {fx} " f"k: {k + 1}/{k_inner_max} " f"k_accepted: {k_accepted}/{k_inner_min} " f"k_noimprovements: {k_noimprovements}") if k_accepted >= k_inner_min: break temp *= alpha # temperature update k_noimprovements += k_accepted == 0 return x, fx
def solve_tsp_simulated_annealing( distance_matrix: np.ndarray, x0: Optional[List[int]] = None, perturbation_scheme: str = "ps3", alpha: float = 0.9, verbose: bool = False, ) -> Tuple[List, float]: """Solve a TSP problem using a Simulated Annealing The approach used here is the one proposed in [1]. Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j x0 Initial permutation. If not provided, it uses a random value perturbation_scheme {"ps1", "ps2", ["ps3"]} Mechanism used to generate new solutions. Defaults to PS3. See [2] for a quick explanation on these schemes. alpha Reduction factor (``alpha`` < 1) used to reduce the temperature. As a rule of thumb, 0.99 takes longer but may return better solutions, whike 0.9 is faster but may not be as good. A good approach is to use 0.9 (as default) and if required run the returned solution with a local search. verbose `True` to display information about the process. Returns ------- A permutation of nodes from 0 to n that produces the least total distance obtained (not necessarily optimal). The total distance the returned permutation produces. References ---------- [1] Dréo, Johann, et al. Metaheuristics for hard optimization: methods and case studies. Springer Science & Business Media, 2006. [2] Goulart, Fillipe, et al. "Permutation-based optimization for the load restoration problem with improved time estimation of maneuvers." International Journal of Electrical Power & Energy Systems 101 (2018): 339-355. """ x, fx = local_search._setup(distance_matrix, x0) temp = initial_temperature(distance_matrix, x, fx, perturbation_scheme) n = len(x) k_inner_min = 12 * n # min inner iterations k_inner_max = 100 * n # max inner iterations k_noimprovements = 0 # number of inner loops without improvement while k_noimprovements < 3: k_accepted = 0 # number of accepted perturbations for k in range(k_inner_max): xn = _perturbation(x, perturbation_scheme) fn = compute_permutation_distance(distance_matrix, xn) if acceptance_rule(fx, fn, temp): x, fx = xn, fn k_accepted += 1 k_noimprovements = 0 if verbose: print((f"Temperature {temp}. Current value: {fx} " f"k: {k + 1}/{k_inner_max} " f"k_accepted: {k_accepted}/{k_inner_min} " f"k_noimprovements: {k_noimprovements} "), end="\r") if k_accepted >= k_inner_min: break temp *= alpha # temperature update k_noimprovements += k_accepted == 0 if verbose: print("") # line break return x, fx
def solve_tsp_local_search( distance_matrix: np.ndarray, x0: Optional[List[int]] = None, perturbation_scheme: str = "two_opt", max_processing_time: Optional[float] = None, log_file: Optional[str] = None, ) -> Tuple[List, float]: """Solve a TSP problem with a local search heuristic Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j x0 Initial permutation. If not provided, it starts with a random path perturbation_scheme {"ps1", "ps2", "ps3", "ps4", "ps5", "ps6", ["two_opt"]} Mechanism used to generate new solutions. Defaults to "two_opt" max_processing_time {None} Maximum processing time in seconds. If not provided, the method stops only when a local minimum is obtained log_file If not `None`, creates a log file with details about the whole execution Returns ------- A permutation of nodes from 0 to n - 1 that produces the least total distance obtained (not necessarily optimal). The total distance the returned permutation produces. Notes ----- Here are the steps of the algorithm: 1. Let `x`, `fx` be a initial solution permutation and its objective value; 2. Perform a neighborhood search in `x`: 2.1 For each `x'` neighbor of `x`, if `fx'` < `fx`, set `x` <- `x'` and stop; 3. Repeat step 2 until all neighbors of `x` are tried and there is no improvement. Return `x`, `fx` as solution. """ x, fx = setup(distance_matrix, x0) max_processing_time = max_processing_time or np.inf if log_file: fh = logging.FileHandler(log_file) fh.setLevel(logging.INFO) logger.addHandler(fh) logger.setLevel(logging.INFO) tic = default_timer() stop_early = False improvement = True while improvement and (not stop_early): improvement = False for n_index, xn in enumerate(neighborhood_gen[perturbation_scheme](x)): if default_timer() - tic > max_processing_time: logger.warning("Stopping early due to time constraints") stop_early = True break fn = compute_permutation_distance(distance_matrix, xn) logger.info(f"Current value: {fx}; Neighbor: {n_index}") if fn < fx: improvement = True x, fx = xn, fn break # early stop due to first improvement local search return x, fx
def solve_tsp_local_search( distance_matrix: np.ndarray, x0: Optional[List[int]] = None, perturbation_scheme: str = "ps3", ) -> Tuple[List, float]: """Solve a TSP problem with a local search heuristic Parameters ---------- distance_matrix Distance matrix of shape (n x n) with the (i, j) entry indicating the distance from node i to j x0 Initial permutation. If not provided, it uses a random value perturbation_scheme {"ps1", "ps2", ["ps3"]} Mechanism used to generate new solutions. Defaults to PS3. See [1] for a quick explanation on these schemes. Returns ------- A permutation of nodes from 0 to n that produces the least total distance obtained (not necessarily optimal). The total distance the returned permutation produces. Notes ----- Here are the steps of the algorithm: 1. Let `x`, `fx` be a initial solution permutation and its objective value; 2. Perform a neighborhood search in `x`: 2.1 For each `x'` neighbor of `x`, if `fx'` < `fx`, set `x` <- `x'` and stop; 3. Repeat step 2 until all neighbors of `x` are tried and there is no improvement. Return `x`, `fx` as solution. References ---------- [1] Goulart, Fillipe, et al. "Permutation-based optimization for the load restoration problem with improved time estimation of maneuvers." International Journal of Electrical Power & Energy Systems 101 (2018): 339-355. """ neighborhood_gen: Dict[str, Callable[[List[int]], Generator[List[int], List[int], None]]] = { "ps1": ps1_gen, "ps2": ps2_gen, "ps3": ps3_gen, } x, fx = _setup(distance_matrix, x0) improvement = True while improvement: improvement = False for xn in neighborhood_gen[perturbation_scheme](x): fn = compute_permutation_distance(distance_matrix, xn) if fn < fx: improvement = True x, fx = xn, fn break # early stop due to first improvement local search return x, fx