From 3d583d2c5dbb3d00c36a3af12ebaaae104c86639 Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:14:52 -0500 Subject: [PATCH 01/12] Added Seedable Random Number Generator to Schemes --- python_tsp/heuristics/perturbation_schemes.py | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/python_tsp/heuristics/perturbation_schemes.py b/python_tsp/heuristics/perturbation_schemes.py index 3232573..9dd5bb1 100644 --- a/python_tsp/heuristics/perturbation_schemes.py +++ b/python_tsp/heuristics/perturbation_schemes.py @@ -12,64 +12,69 @@ 339-355. .. [2] 2-opt: https://en.wikipedia.org/wiki/2-opt """ -from random import sample +from random import Random from typing import Callable, Dict, Generator, List +initial_random_generator = Random() -def ps1_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps1_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS1 perturbation scheme: Swap two adjacent terms [1] This scheme has at most n - 1 swaps. """ + rng = rng or initial_random_generator n = len(x) i_range = range(1, n - 1) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): xn = x.copy() xn[i], xn[i + 1] = xn[i + 1], xn[i] yield xn -def ps2_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps2_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS2 perturbation scheme: Swap any two elements [1] This scheme has n * (n - 1) / 2 swaps. """ + rng = rng or initial_random_generator n = len(x) i_range = range(1, n - 1) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = range(i + 1, n) - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): xn = x.copy() xn[i], xn[j] = xn[j], xn[i] yield xn -def ps3_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps3_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS3 perturbation scheme: A single term is moved [1] This scheme has n * (n - 1) swaps. """ + rng = rng or initial_random_generator n = len(x) i_range = range(1, n) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = [j for j in range(1, n) if j != i] - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): xn = x.copy() node = xn.pop(i) xn.insert(j, node) yield xn -def ps4_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps4_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS4 perturbation scheme: A subsequence is moved [1]""" + rng = rng or initial_random_generator n = len(x) i_range = range(1, n) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = range(i + 1, n + 1) - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): k_range = [k for k in range(1, n + 1) if k not in range(i, j + 1)] - for k in sample(k_range, len(k_range)): + for k in rng.sample(k_range, len(k_range)): xn = x.copy() if k < i: xn = xn[:k] + xn[i:j] + xn[k:i] + xn[j:] @@ -78,29 +83,31 @@ def ps4_gen(x: List[int]) -> Generator[List[int], List[int], None]: yield xn -def ps5_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps5_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS5 perturbation scheme: A subsequence is reversed [1]""" + rng = rng or initial_random_generator n = len(x) i_range = range(1, n) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = range(i + 2, n + 1) - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): xn = x.copy() xn = xn[:i] + list(reversed(xn[i:j])) + xn[j:] yield xn -def ps6_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def ps6_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """PS6 perturbation scheme: A subsequence is reversed and moved [1]""" + rng = rng or initial_random_generator n = len(x) i_range = range(1, n) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = range(i + 1, n + 1) - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): k_range = [k for k in range(1, n + 1) if k not in range(i, j + 1)] - for k in sample(k_range, len(k_range)): + for k in rng.sample(k_range, len(k_range)): xn = x.copy() if k < i: xn = xn[:k] + list(reversed(xn[i:j])) + xn[k:i] + xn[j:] @@ -109,13 +116,15 @@ def ps6_gen(x: List[int]) -> Generator[List[int], List[int], None]: yield xn -def two_opt_gen(x: List[int]) -> Generator[List[int], List[int], None]: +def two_opt_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: """2-opt perturbation scheme [2]""" + rng = rng or initial_random_generator + n = len(x) i_range = range(2, n) - for i in sample(i_range, len(i_range)): + for i in rng.sample(i_range, len(i_range)): j_range = range(i + 1, n + 1) - for j in sample(j_range, len(j_range)): + for j in rng.sample(j_range, len(j_range)): xn = x.copy() xn = xn[: i - 1] + list(reversed(xn[i - 1 : j])) + xn[j:] yield xn @@ -123,7 +132,7 @@ def two_opt_gen(x: List[int]) -> Generator[List[int], List[int], None]: # Mapping with all possible neighborhood generators in this module neighborhood_gen: Dict[ - str, Callable[[List[int]], Generator[List[int], List[int], None]] + str, Callable[[List[int], Random], Generator[List[int], List[int], None]] ] = { "ps1": ps1_gen, "ps2": ps2_gen, From 35bb253a40cf2f22056211cf79ce1fe274167b54 Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Fri, 19 Jan 2024 00:14:46 -0500 Subject: [PATCH 02/12] Adding random and max iterations to local search --- python_tsp/heuristics/local_search.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/python_tsp/heuristics/local_search.py b/python_tsp/heuristics/local_search.py index 75f261d..a77d946 100644 --- a/python_tsp/heuristics/local_search.py +++ b/python_tsp/heuristics/local_search.py @@ -1,6 +1,7 @@ """Simple local search solver""" from timeit import default_timer from typing import List, Optional, Tuple, TextIO +from random import Random import numpy as np @@ -12,6 +13,7 @@ TIME_LIMIT_MSG = "WARNING: Stopping early due to time constraints" +ITERATION_LIMIT_MSG = "WARNING: Stopping early due to iteration limit" def solve_tsp_local_search( @@ -19,6 +21,8 @@ def solve_tsp_local_search( x0: Optional[List[int]] = None, perturbation_scheme: str = "two_opt", max_processing_time: Optional[float] = None, + max_iterations: Optional[int] = None, + rng: Optional[Random] = None, log_file: Optional[str] = None, verbose: bool = False, ) -> Tuple[List, float]: @@ -40,6 +44,15 @@ def solve_tsp_local_search( Maximum processing time in seconds. If not provided, the method stops only when a local minimum is obtained + max_iterations {None} + Maximum number of iterations to perform. If not provided, the method + only stops when a local minimum is obtained or if max_processing_time + is provided + + rng + Random number generator to be passed to the pertubation scheme. If not + provided, the initial random generator is used. + log_file If not `None`, creates a log file with details about the whole execution @@ -76,13 +89,20 @@ def solve_tsp_local_search( stop_early = False improvement = True + i = 0 while improvement and (not stop_early): improvement = False - for n_index, xn in enumerate(neighborhood_gen[perturbation_scheme](x)): + for n_index, xn in enumerate(neighborhood_gen[perturbation_scheme](x, rng=rng)): + i += 1 if default_timer() - tic > max_processing_time: _print_message(TIME_LIMIT_MSG, verbose, log_file_handler) stop_early = True break + if max_iterations and i > max_iterations: + _print_message(ITERATION_LIMIT_MSG, verbose, log_file_handler) + stop_early = True + break + fn = compute_permutation_distance(distance_matrix, xn) From dd94ecd1e3ed5a51b24dc9496ec1bb41350a450f Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Fri, 1 Mar 2024 00:47:40 -0500 Subject: [PATCH 03/12] Adding random and max iter to simulated annealing --- python_tsp/heuristics/simulated_annealing.py | 25 ++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/python_tsp/heuristics/simulated_annealing.py b/python_tsp/heuristics/simulated_annealing.py index c19252e..c4cbe65 100644 --- a/python_tsp/heuristics/simulated_annealing.py +++ b/python_tsp/heuristics/simulated_annealing.py @@ -1,5 +1,6 @@ from math import inf from timeit import default_timer +from random import Random from typing import List, Optional, Tuple, TextIO import numpy as np @@ -12,6 +13,7 @@ TIME_LIMIT_MSG = "WARNING: Stopping early due to time constraints" +ITERATION_LIMIT_MSG = "WARNING: Stopping early due to iteration limit" MAX_NON_IMPROVEMENTS = 3 MAX_INNER_ITERATIONS_MULTIPLIER = 10 @@ -22,6 +24,8 @@ def solve_tsp_simulated_annealing( perturbation_scheme: str = "two_opt", alpha: float = 0.9, max_processing_time: Optional[float] = None, + max_iterations: Optional[int] = None, + rng: Optional[Random] = None, log_file: Optional[str] = None, verbose: bool = False, ) -> Tuple[List, float]: @@ -72,7 +76,7 @@ def solve_tsp_simulated_annealing( """ x, fx = setup_initial_solution(distance_matrix, x0) - temp = _initial_temperature(distance_matrix, x, fx, perturbation_scheme) + temp = _initial_temperature(distance_matrix, x, fx, perturbation_scheme, rng=rng) max_processing_time = max_processing_time or inf log_file_handler = ( open(log_file, "w", encoding="utf-8") if log_file else None @@ -85,15 +89,21 @@ def solve_tsp_simulated_annealing( tic = default_timer() stop_early = False + i = 0 while (k_noimprovements < MAX_NON_IMPROVEMENTS) and (not stop_early): k_accepted = 0 # number of accepted perturbations for k in range(k_inner_max): + i += 1 if default_timer() - tic > max_processing_time: _print_message(TIME_LIMIT_MSG, verbose, log_file_handler) stop_early = True break + if max_iterations and i > max_iterations: + _print_message(ITERATION_LIMIT_MSG, verbose, log_file_handler) + stop_early = True + break - xn = _perturbation(x, perturbation_scheme) + xn = _perturbation(x, perturbation_scheme, rng=rng) fn = compute_permutation_distance(distance_matrix, xn) if _acceptance_rule(fx, fn, temp): @@ -136,6 +146,7 @@ def _initial_temperature( x: List[int], fx: float, perturbation_scheme: str, + rng: Optional[Random], ) -> float: """Compute initial temperature Instead of relying on problem-dependent parameters, this function estimates @@ -159,7 +170,7 @@ def _initial_temperature( # Step 1 dfx_list = [] for _ in range(100): - xn = _perturbation(x, perturbation_scheme) + xn = _perturbation(x, perturbation_scheme, rng=rng) fn = compute_permutation_distance(distance_matrix, xn) dfx_list.append(fn - fx) @@ -170,19 +181,19 @@ def _initial_temperature( return -dfx_mean / np.log(tau0) -def _perturbation(x: List[int], perturbation_scheme: str): +def _perturbation(x: List[int], perturbation_scheme: str, rng: Optional[Random]): """Generate a random neighbor of a current solution ``x`` In this case, we can use the generators created in the `local_search` module, and pick the first solution. Since the neighborhood is randomized, it is the same as creating a random perturbation. """ - return next(neighborhood_gen[perturbation_scheme](x)) + return next(neighborhood_gen[perturbation_scheme](x, rng=rng)) -def _acceptance_rule(fx: float, fn: float, temp: float) -> bool: +def _acceptance_rule(fx: float, fn: float, temp: float, rng: Optional[Random]) -> bool: """Metropolis acceptance rule""" dfx = fn - fx return (dfx < 0) or ( - (dfx > 0) and (np.random.rand() <= np.exp(-(fn - fx) / temp)) + (dfx > 0) and (rng.random() <= np.exp(-(fn - fx) / temp)) ) From b7fbc4241f7a34022251b5d9120f215535049343 Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Fri, 1 Mar 2024 01:25:05 -0500 Subject: [PATCH 04/12] minor fixups --- python_tsp/heuristics/simulated_annealing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python_tsp/heuristics/simulated_annealing.py b/python_tsp/heuristics/simulated_annealing.py index c4cbe65..6831792 100644 --- a/python_tsp/heuristics/simulated_annealing.py +++ b/python_tsp/heuristics/simulated_annealing.py @@ -106,7 +106,7 @@ def solve_tsp_simulated_annealing( xn = _perturbation(x, perturbation_scheme, rng=rng) fn = compute_permutation_distance(distance_matrix, xn) - if _acceptance_rule(fx, fn, temp): + if _acceptance_rule(fx, fn, temp, rng=rng): x, fx = xn, fn k_accepted += 1 k_noimprovements = 0 @@ -192,8 +192,9 @@ def _perturbation(x: List[int], perturbation_scheme: str, rng: Optional[Random] def _acceptance_rule(fx: float, fn: float, temp: float, rng: Optional[Random]) -> bool: """Metropolis acceptance rule""" + rand = rng.random() if rng else np.random.rand() dfx = fn - fx return (dfx < 0) or ( - (dfx > 0) and (rng.random() <= np.exp(-(fn - fx) / temp)) + (dfx > 0) and (rand <= np.exp(-(fn - fx) / temp)) ) From e7bc5965ef2818eb281b92c7e34262eb74dd1c5f Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:01:30 -0400 Subject: [PATCH 05/12] Adding random number generator on Record to Record --- python_tsp/heuristics/record_to_record.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/python_tsp/heuristics/record_to_record.py b/python_tsp/heuristics/record_to_record.py index 89a0a60..fbfbede 100644 --- a/python_tsp/heuristics/record_to_record.py +++ b/python_tsp/heuristics/record_to_record.py @@ -1,4 +1,4 @@ -from random import randint +from random import Random, randint from typing import List, Optional, TextIO import numpy as np @@ -21,6 +21,7 @@ def solve_tsp_record_to_record( distance_matrix: np.ndarray, x0: Optional[List[int]] = None, max_iterations: Optional[int] = None, + rng: Optional[Random] = None, log_file: Optional[str] = None, verbose: bool = False, ): @@ -41,6 +42,10 @@ def solve_tsp_record_to_record( The maximum number of iterations for the algorithm. If not specified, it defaults to the number of nodes in the distance matrix. + rng + Random number generator to be passed to the pertubation scheme. If not + provided, the initial random generator is used. + log_file If not `None`, creates a log file with details about the whole execution. @@ -62,6 +67,8 @@ def solve_tsp_record_to_record( max_iterations = max_iterations or n x, fx = setup_initial_solution(distance_matrix=distance_matrix, x0=x0) + new_int = rng.randint if rng else randint + log_file_handler = ( open(log_file, "w", encoding="utf-8") if log_file else None ) @@ -69,8 +76,8 @@ def solve_tsp_record_to_record( for iteration in range(1, max_iterations + 1): xn = x[:] for _ in range(2): - u = randint(1, n - 1) - v = randint(1, n - 1) + u = new_int(1, n - 1) + v = new_int(1, n - 1) xn[u], xn[v] = xn[v], xn[u] xn, fn = solve_tsp_lin_kernighan( From 0b6c7fd91415b067ab6998290ef31e2b690d012b Mon Sep 17 00:00:00 2001 From: Addison Hanrattie <40609224+supersimple33@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:08:40 -0400 Subject: [PATCH 06/12] Updating docs with RNG info --- docs/solvers.rst | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/solvers.rst b/docs/solvers.rst index 5c4476b..8906a5f 100644 --- a/docs/solvers.rst +++ b/docs/solvers.rst @@ -111,13 +111,22 @@ Notice this local optimum may be different for distinct perturbation schemes and max_processing_time {None} Maximum processing time in seconds. If not provided, the method stops - only when a local minimum is obtained + only when a local minimum is obtained or if max_iterations is set. - log_file + max_iterations {None} + Maximum number of iteratons to run. If not provided, the method stops + only when a local minimum is obtained or if max_processing_time is set. + + rng {None} + A python random number generator which can be seeded to ensure + consistent results given a call to this function. If not provided + the initial random number generator is used. + + log_file {None} If not `None`, creates a log file with details about the whole execution - verbose + verbose {False} If true, prints algorithm status every iteration @@ -138,6 +147,8 @@ An implementation of the `Simulated Annealing Date: Thu, 23 Jan 2025 10:27:51 -0500 Subject: [PATCH 07/12] make type a union --- python_tsp/heuristics/perturbation_schemes.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/python_tsp/heuristics/perturbation_schemes.py b/python_tsp/heuristics/perturbation_schemes.py index c642cb1..0b79b5a 100644 --- a/python_tsp/heuristics/perturbation_schemes.py +++ b/python_tsp/heuristics/perturbation_schemes.py @@ -14,11 +14,14 @@ """ from random import Random -from typing import Callable, Dict, Generator, List +from typing import Callable, Dict, Generator, List, Union initial_random_generator = Random() -def ps1_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: + +def ps1_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS1 perturbation scheme: Swap two adjacent terms [1] This scheme has at most n - 1 swaps. """ @@ -32,7 +35,9 @@ def ps1_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def ps2_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def ps2_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS2 perturbation scheme: Swap any two elements [1] This scheme has n * (n - 1) / 2 swaps. """ @@ -48,7 +53,9 @@ def ps2_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def ps3_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def ps3_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS3 perturbation scheme: A single term is moved [1] This scheme has n * (n - 1) swaps. """ @@ -65,7 +72,9 @@ def ps3_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def ps4_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def ps4_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS4 perturbation scheme: A subsequence is moved [1]""" rng = rng or initial_random_generator @@ -84,7 +93,9 @@ def ps4_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def ps5_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def ps5_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS5 perturbation scheme: A subsequence is reversed [1]""" rng = rng or initial_random_generator @@ -98,7 +109,9 @@ def ps5_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def ps6_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def ps6_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """PS6 perturbation scheme: A subsequence is reversed and moved [1]""" rng = rng or initial_random_generator @@ -117,10 +130,12 @@ def ps6_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], yield xn -def two_opt_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[int], None]: +def two_opt_gen( + x: List[int], rng: Union[Random, None] = None +) -> Generator[List[int], List[int], None]: """2-opt perturbation scheme [2]""" rng = rng or initial_random_generator - + n = len(x) i_range = range(2, n) for i in rng.sample(i_range, len(i_range)): @@ -133,7 +148,10 @@ def two_opt_gen(x: List[int], rng: Random = None) -> Generator[List[int], List[i # Mapping with all possible neighborhood generators in this module neighborhood_gen: Dict[ - str, Callable[[List[int], Random], Generator[List[int], List[int], None]] + str, + Callable[ + [List[int], Union[Random, None]], Generator[List[int], List[int], None] + ], ] = { "ps1": ps1_gen, "ps2": ps2_gen, From dd2212e05ac364b2ff7f8b71601adebb79682c38 Mon Sep 17 00:00:00 2001 From: supersimple33 <40609224+supersimple33@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:28:24 -0500 Subject: [PATCH 08/12] formatting and call format --- python_tsp/heuristics/local_search.py | 7 ++++--- python_tsp/heuristics/record_to_record.py | 2 +- python_tsp/heuristics/simulated_annealing.py | 18 +++++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/python_tsp/heuristics/local_search.py b/python_tsp/heuristics/local_search.py index af357bc..235ae06 100644 --- a/python_tsp/heuristics/local_search.py +++ b/python_tsp/heuristics/local_search.py @@ -49,7 +49,7 @@ def solve_tsp_local_search( Maximum number of iterations to perform. If not provided, the method only stops when a local minimum is obtained or if max_processing_time is provided - + rng Random number generator to be passed to the pertubation scheme. If not provided, the initial random generator is used. @@ -93,7 +93,9 @@ def solve_tsp_local_search( i = 0 while improvement and (not stop_early): improvement = False - for n_index, xn in enumerate(neighborhood_gen[perturbation_scheme](x, rng=rng)): + for n_index, xn in enumerate( + neighborhood_gen[perturbation_scheme](x, rng) + ): i += 1 if default_timer() - tic > max_processing_time: _print_message(TIME_LIMIT_MSG, verbose, log_file_handler) @@ -103,7 +105,6 @@ def solve_tsp_local_search( _print_message(ITERATION_LIMIT_MSG, verbose, log_file_handler) stop_early = True break - fn = compute_permutation_distance(distance_matrix, xn) diff --git a/python_tsp/heuristics/record_to_record.py b/python_tsp/heuristics/record_to_record.py index fbfbede..da22e1f 100644 --- a/python_tsp/heuristics/record_to_record.py +++ b/python_tsp/heuristics/record_to_record.py @@ -45,7 +45,7 @@ def solve_tsp_record_to_record( rng Random number generator to be passed to the pertubation scheme. If not provided, the initial random generator is used. - + log_file If not `None`, creates a log file with details about the whole execution. diff --git a/python_tsp/heuristics/simulated_annealing.py b/python_tsp/heuristics/simulated_annealing.py index 6831792..39f444e 100644 --- a/python_tsp/heuristics/simulated_annealing.py +++ b/python_tsp/heuristics/simulated_annealing.py @@ -76,7 +76,9 @@ def solve_tsp_simulated_annealing( """ x, fx = setup_initial_solution(distance_matrix, x0) - temp = _initial_temperature(distance_matrix, x, fx, perturbation_scheme, rng=rng) + temp = _initial_temperature( + distance_matrix, x, fx, perturbation_scheme, rng=rng + ) max_processing_time = max_processing_time or inf log_file_handler = ( open(log_file, "w", encoding="utf-8") if log_file else None @@ -181,20 +183,22 @@ def _initial_temperature( return -dfx_mean / np.log(tau0) -def _perturbation(x: List[int], perturbation_scheme: str, rng: Optional[Random]): +def _perturbation( + x: List[int], perturbation_scheme: str, rng: Optional[Random] +): """Generate a random neighbor of a current solution ``x`` In this case, we can use the generators created in the `local_search` module, and pick the first solution. Since the neighborhood is randomized, it is the same as creating a random perturbation. """ - return next(neighborhood_gen[perturbation_scheme](x, rng=rng)) + return next(neighborhood_gen[perturbation_scheme](x, rng)) -def _acceptance_rule(fx: float, fn: float, temp: float, rng: Optional[Random]) -> bool: +def _acceptance_rule( + fx: float, fn: float, temp: float, rng: Optional[Random] +) -> bool: """Metropolis acceptance rule""" rand = rng.random() if rng else np.random.rand() dfx = fn - fx - return (dfx < 0) or ( - (dfx > 0) and (rand <= np.exp(-(fn - fx) / temp)) - ) + return (dfx < 0) or ((dfx > 0) and (rand <= np.exp(-(fn - fx) / temp))) From e39e6dd1a5f8fadb898fddc428be8cefa6237f30 Mon Sep 17 00:00:00 2001 From: supersimple33 <40609224+supersimple33@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:59:58 -0500 Subject: [PATCH 09/12] adding new call check test --- tests/heuristics/test_local_search.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/heuristics/test_local_search.py b/tests/heuristics/test_local_search.py index a2b6c0c..601fa80 100644 --- a/tests/heuristics/test_local_search.py +++ b/tests/heuristics/test_local_search.py @@ -1,3 +1,4 @@ +import random import sys from io import StringIO @@ -68,6 +69,31 @@ def test_local_search_returns_equal_optimal_solution( assert fopt == fx +@pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_local_search_calls_rng(scheme, distance_matrix): + """ + Ensure that the rng is actually called when solving the TSP. + """ + + called = False + + class psuedo_rng(random.Random): + def getrandbits(self, k: int, /) -> int: + nonlocal called + called = True + return super().getrandbits(k) + + x = [0, 4, 2, 3, 1] + local_search.solve_tsp_local_search( + distance_matrix, x, perturbation_scheme=scheme, rng=psuedo_rng(42) + ) + + assert called + + @pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) def test_local_search_with_time_constraints(scheme): """ From 86ca8c6351015b23a93725e8c7c677873b05b8e9 Mon Sep 17 00:00:00 2001 From: supersimple33 <40609224+supersimple33@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:08:43 -0500 Subject: [PATCH 10/12] adding two more call tests --- tests/heuristics/test_record_to_record.py | 27 ++++++++++++++++++++ tests/heuristics/test_simulated_annealing.py | 25 ++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/tests/heuristics/test_record_to_record.py b/tests/heuristics/test_record_to_record.py index feee246..571d936 100644 --- a/tests/heuristics/test_record_to_record.py +++ b/tests/heuristics/test_record_to_record.py @@ -1,3 +1,4 @@ +import random import pytest from python_tsp.heuristics import solve_tsp_record_to_record @@ -77,6 +78,32 @@ def test_record_to_record_returns_equal_optimal_solution( assert fopt == optimal_distance +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_record_to_record_calls_rng(distance_matrix): + """ + Ensure that the rng is actually called when solving the TSP. + """ + + called = False + + class psuedo_rng(random.Random): + def getrandbits(self, k: int, /) -> int: + nonlocal called + called = True + return super().getrandbits(k) + + x0 = [0, 4, 2, 3, 1] + solve_tsp_record_to_record( + distance_matrix=distance_matrix, + x0=x0, + rng=psuedo_rng(42), + ) + + assert called + + def test_record_to_record_log_file_is_created_if_required(tmp_path): """ If a log_file is provided, it contains information about the execution. diff --git a/tests/heuristics/test_simulated_annealing.py b/tests/heuristics/test_simulated_annealing.py index c7a937b..afd50b4 100644 --- a/tests/heuristics/test_simulated_annealing.py +++ b/tests/heuristics/test_simulated_annealing.py @@ -1,3 +1,4 @@ +import random import sys from io import StringIO @@ -69,6 +70,30 @@ def test_simulated_annealing_with_time_constraints(permutation, scheme): assert simulated_annealing.TIME_LIMIT_MSG in captured_output.getvalue() +@pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_simulated_annealing_calls_rng(scheme, distance_matrix): + """ + Ensure that the rng is actually called when solving the TSP. + """ + + called = False + + class psuedo_rng(random.Random): + def getrandbits(self, k: int, /) -> int: + nonlocal called + called = True + return super().getrandbits(k) + + simulated_annealing.solve_tsp_simulated_annealing( + distance_matrix, perturbation_scheme=scheme, rng=psuedo_rng(42) + ) + + assert called + + def test_log_file_is_created_if_required(permutation, tmp_path): """ If a log_file is provided, it contains information about the execution. From 200bdcb5a45ffcc441acdb6bd3f7d11b6e9539c3 Mon Sep 17 00:00:00 2001 From: supersimple33 <40609224+supersimple33@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:20:42 -0500 Subject: [PATCH 11/12] adding new equal solution tests --- tests/heuristics/test_local_search.py | 24 +++++++++++++++++++- tests/heuristics/test_record_to_record.py | 23 ++++++++++++++++++- tests/heuristics/test_simulated_annealing.py | 24 +++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/heuristics/test_local_search.py b/tests/heuristics/test_local_search.py index 601fa80..a1a19e3 100644 --- a/tests/heuristics/test_local_search.py +++ b/tests/heuristics/test_local_search.py @@ -88,12 +88,34 @@ def getrandbits(self, k: int, /) -> int: x = [0, 4, 2, 3, 1] local_search.solve_tsp_local_search( - distance_matrix, x, perturbation_scheme=scheme, rng=psuedo_rng(42) + distance_matrix, x, perturbation_scheme=scheme, rng=psuedo_rng(0) ) assert called +@pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_local_search_eql_with_same_seed(scheme, distance_matrix): + """ + Ensure that the same solution is returned when using the same seed. + """ + + x = [0, 4, 2, 3, 1] + xopt, fopt = local_search.solve_tsp_local_search( + distance_matrix, x, perturbation_scheme=scheme, rng=random.Random(42) + ) + + xopt2, fopt2 = local_search.solve_tsp_local_search( + distance_matrix, x, perturbation_scheme=scheme, rng=random.Random(42) + ) + + assert all(e1 == e2 for e1, e2 in zip(xopt, xopt2)) + assert fopt == fopt2 + + @pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) def test_local_search_with_time_constraints(scheme): """ diff --git a/tests/heuristics/test_record_to_record.py b/tests/heuristics/test_record_to_record.py index 571d936..fb29047 100644 --- a/tests/heuristics/test_record_to_record.py +++ b/tests/heuristics/test_record_to_record.py @@ -98,12 +98,33 @@ def getrandbits(self, k: int, /) -> int: solve_tsp_record_to_record( distance_matrix=distance_matrix, x0=x0, - rng=psuedo_rng(42), + rng=psuedo_rng(0), ) assert called +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_record_to_record_eql_with_same_seed(distance_matrix): + """ + Ensure that the same solution is returned when using the same seed. + """ + + x0 = [0, 4, 2, 3, 1] + xopt, fopt = solve_tsp_record_to_record( + distance_matrix=distance_matrix, x0=x0, rng=random.Random(42) + ) + + xopt2, fopt2 = solve_tsp_record_to_record( + distance_matrix=distance_matrix, x0=x0, rng=random.Random(42) + ) + + assert all(e1 == e2 for e1, e2 in zip(xopt, xopt2)) + assert fopt == fopt2 + + def test_record_to_record_log_file_is_created_if_required(tmp_path): """ If a log_file is provided, it contains information about the execution. diff --git a/tests/heuristics/test_simulated_annealing.py b/tests/heuristics/test_simulated_annealing.py index afd50b4..3065512 100644 --- a/tests/heuristics/test_simulated_annealing.py +++ b/tests/heuristics/test_simulated_annealing.py @@ -88,12 +88,34 @@ def getrandbits(self, k: int, /) -> int: return super().getrandbits(k) simulated_annealing.solve_tsp_simulated_annealing( - distance_matrix, perturbation_scheme=scheme, rng=psuedo_rng(42) + distance_matrix, perturbation_scheme=scheme, rng=psuedo_rng(0) ) assert called +@pytest.mark.parametrize("scheme", PERTURBATION_SCHEMES) +@pytest.mark.parametrize( + "distance_matrix", [distance_matrix1, distance_matrix2, distance_matrix3] +) +def test_simulated_annealing_eql_with_same_seed(scheme, distance_matrix): + """ + Ensure that the same solution is returned when using the same seed. + """ + + x = [0, 4, 2, 3, 1] + xopt, fopt = simulated_annealing.solve_tsp_simulated_annealing( + distance_matrix, x, perturbation_scheme=scheme, rng=random.Random(42) + ) + + xopt2, fopt2 = simulated_annealing.solve_tsp_simulated_annealing( + distance_matrix, x, perturbation_scheme=scheme, rng=random.Random(42) + ) + + assert all(e1 == e2 for e1, e2 in zip(xopt, xopt2)) + assert fopt == fopt2 + + def test_log_file_is_created_if_required(permutation, tmp_path): """ If a log_file is provided, it contains information about the execution. From 91a52d4fec5d97e4bc4fff00bf8edf5716ec935b Mon Sep 17 00:00:00 2001 From: supersimple33 <40609224+supersimple33@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:34:54 -0500 Subject: [PATCH 12/12] Removing max iterations --- docs/solvers.rst | 9 --------- python_tsp/heuristics/local_search.py | 12 ------------ python_tsp/heuristics/simulated_annealing.py | 8 -------- 3 files changed, 29 deletions(-) diff --git a/docs/solvers.rst b/docs/solvers.rst index 8906a5f..636b2df 100644 --- a/docs/solvers.rst +++ b/docs/solvers.rst @@ -113,10 +113,6 @@ Notice this local optimum may be different for distinct perturbation schemes and Maximum processing time in seconds. If not provided, the method stops only when a local minimum is obtained or if max_iterations is set. - max_iterations {None} - Maximum number of iteratons to run. If not provided, the method stops - only when a local minimum is obtained or if max_processing_time is set. - rng {None} A python random number generator which can be seeded to ensure consistent results given a call to this function. If not provided @@ -179,11 +175,6 @@ An implementation of the `Simulated Annealing max_processing_time: _print_message(TIME_LIMIT_MSG, verbose, log_file_handler) stop_early = True break - if max_iterations and i > max_iterations: - _print_message(ITERATION_LIMIT_MSG, verbose, log_file_handler) - stop_early = True - break fn = compute_permutation_distance(distance_matrix, xn) diff --git a/python_tsp/heuristics/simulated_annealing.py b/python_tsp/heuristics/simulated_annealing.py index 39f444e..b9dfb3c 100644 --- a/python_tsp/heuristics/simulated_annealing.py +++ b/python_tsp/heuristics/simulated_annealing.py @@ -13,7 +13,6 @@ TIME_LIMIT_MSG = "WARNING: Stopping early due to time constraints" -ITERATION_LIMIT_MSG = "WARNING: Stopping early due to iteration limit" MAX_NON_IMPROVEMENTS = 3 MAX_INNER_ITERATIONS_MULTIPLIER = 10 @@ -24,7 +23,6 @@ def solve_tsp_simulated_annealing( perturbation_scheme: str = "two_opt", alpha: float = 0.9, max_processing_time: Optional[float] = None, - max_iterations: Optional[int] = None, rng: Optional[Random] = None, log_file: Optional[str] = None, verbose: bool = False, @@ -91,19 +89,13 @@ def solve_tsp_simulated_annealing( tic = default_timer() stop_early = False - i = 0 while (k_noimprovements < MAX_NON_IMPROVEMENTS) and (not stop_early): k_accepted = 0 # number of accepted perturbations for k in range(k_inner_max): - i += 1 if default_timer() - tic > max_processing_time: _print_message(TIME_LIMIT_MSG, verbose, log_file_handler) stop_early = True break - if max_iterations and i > max_iterations: - _print_message(ITERATION_LIMIT_MSG, verbose, log_file_handler) - stop_early = True - break xn = _perturbation(x, perturbation_scheme, rng=rng) fn = compute_permutation_distance(distance_matrix, xn)