amachine.am_optimize.am_optimize_node_permutation_ils
1import networkx as nx 2import numpy as np 3from numba import njit 4 5from ..am_random import resolve_rng 6 7def _calculate_score( 8 G: nx.DiGraph, 9 instance_cohesion: np.ndarray, 10 perm: list[int] 11) -> float: 12 13 nodes = list(G.nodes()) 14 node_to_idx = {node: i for i, node in enumerate(nodes)} 15 16 total_score = 0.0 17 for u, v in G.edges(): 18 19 i = node_to_idx[u] 20 j = node_to_idx[v] 21 22 assigned_instance_u = perm[i] 23 assigned_instance_v = perm[j] 24 25 total_score += instance_cohesion[assigned_instance_u, assigned_instance_v] 26 27 return total_score 28 29@njit 30def evaluate_swap_delta(a, b, perm, A, C, N): 31 """Calculates the exact change in objective if nodes A and B swap their instance assignments.""" 32 33 p_a = perm[a] 34 p_b = perm[b] 35 36 delta = 0.0 37 38 for i in range(N): 39 40 if i == a or i == b: 41 continue 42 43 p_i = perm[i] 44 45 # Edges connected to A 46 if A[i, a] > 0: delta += A[i, a] * (C[p_i, p_b] - C[p_i, p_a]) 47 if A[a, i] > 0: delta += A[a, i] * (C[p_b, p_i] - C[p_a, p_i]) 48 49 # Edges connected to B 50 if A[i, b] > 0: delta += A[i, b] * (C[p_i, p_a] - C[p_i, p_b]) 51 if A[b, i] > 0: delta += A[b, i] * (C[p_a, p_i] - C[p_b, p_i]) 52 53 # Handle A and B connected directly to each other 54 if A[a, b] > 0: delta += A[a, b] * (C[p_b, p_a] - C[p_a, p_b]) 55 if A[b, a] > 0: delta += A[b, a] * (C[p_a, p_b] - C[p_b, p_a]) 56 57 # Handle self-loops correctly if they exist in the graph 58 if A[a, a] > 0: delta += A[a, a] * (C[p_b, p_b] - C[p_a, p_a]) 59 if A[b, b] > 0: delta += A[b, b] * (C[p_a, p_a] - C[p_b, p_b]) 60 61 return delta 62 63@njit 64def steepest_descent(perm, A, C, N): 65 """Explores the full neighborhood and applies the steepest ascent swap until stuck.""" 66 improved = True 67 current_perm = perm.copy() 68 69 while improved: 70 improved = False 71 best_delta = 1e-9 # Must strictly improve 72 best_a = -1 73 best_b = -1 74 75 for a in range(N): 76 for b in range(a + 1, N): 77 delta = evaluate_swap_delta(a, b, current_perm, A, C, N) 78 if delta > best_delta: 79 best_delta = delta 80 best_a = a 81 best_b = b 82 83 # Apply the best move found 84 if best_a != -1: 85 tmp = current_perm[best_a] 86 current_perm[best_a] = current_perm[best_b] 87 current_perm[best_b] = tmp 88 improved = True 89 90 return current_perm 91 92@njit 93def set_numba_seed(seed): 94 np.random.seed(seed) 95 96@njit 97def perturb(perm, N, kick_strength): 98 """Executes a random walk of K valid swaps to escape local optima.""" 99 new_perm = perm.copy() 100 swaps_done = 0 101 attempts = 0 102 103 while swaps_done < kick_strength and attempts < 200: 104 attempts += 1 105 106 a = np.random.randint(0, N) 107 b = np.random.randint(0, N) 108 109 # In a pure permutation without graph state restrictions, 110 # the only invalid swap is swapping an index with itself. 111 if a == b: 112 continue 113 114 # Swap 115 tmp = new_perm[a] 116 new_perm[a] = new_perm[b] 117 new_perm[b] = tmp 118 swaps_done += 1 119 120 return new_perm 121 122@njit 123def ils_engine(initial_permutation, A, C, N, max_iterations, kick_strength, random_seed=None): 124 125 if random_seed is not None: 126 set_numba_seed(random_seed) 127 128 best_permutation = steepest_descent(initial_permutation, A, C, N) 129 130 # Calculate baseline absolute energy (objective is maximization) 131 best_energy = 0.0 132 for i in range(N): 133 for j in range(N): 134 if A[i, j] > 0: 135 best_energy += A[i, j] * C[best_permutation[i], best_permutation[j]] 136 137 for i in range(max_iterations): 138 perturbed_perm = perturb(best_permutation, N, kick_strength) 139 candidate_perm = steepest_descent(perturbed_perm, A, C, N) 140 141 candidate_energy = 0.0 142 for x in range(N): 143 for y in range(N): 144 if A[x, y] > 0: 145 candidate_energy += A[x, y] * C[candidate_perm[x], candidate_perm[y]] 146 147 # Accept if it is a new global best 148 if candidate_energy > best_energy: 149 best_energy = candidate_energy 150 best_permutation = candidate_perm.copy() 151 152 return best_permutation, best_energy 153 154def optimize_node_permutation_ils( 155 G: nx.DiGraph, 156 cohesion_matrix: np.ndarray, 157 ils_iterations: int = 5000, 158 kick_strength: int = 4, 159 np_rng: np.random.Generator | None = None 160) -> list[int]: 161 """ 162 Optimizes the node assignment utilizing Iterated Local Search. 163 Maximizes the summed cohesion weights across the directed graph. 164 """ 165 np_rng = resolve_rng( np_rng ) 166 numba_seed = np_rng.integers(0, 2**32 - 1) 167 168 # Convert to 64-bit float for Numba compatibility 169 # (avoiding the 10_000 integer scaling needed for CP-SAT) 170 C = cohesion_matrix.astype(np.float64) 171 172 N = len(list(G.nodes())) 173 node_to_idx = {node: i for i, node in enumerate(G.nodes())} 174 175 # Build an adjacency weight matrix from the graph 176 A = np.zeros((N, N), dtype=np.float64) 177 for u, v in G.edges(): 178 i, j = node_to_idx[u], node_to_idx[v] 179 A[i, j] += 1.0 180 181 # Start from a randomized initial state 182 initial_permutation = np.arange(N, dtype=np.int32) 183 np_rng.shuffle(initial_permutation) 184 185 best_permutation, _ = ils_engine( 186 initial_permutation, 187 A, 188 C, 189 N, 190 ils_iterations, 191 kick_strength, 192 numba_seed 193 ) 194 195 print( f"ils node permuation score {_calculate_score(G,cohesion_matrix,best_permutation)}" ) 196 197 return best_permutation.tolist()
@njit
def
evaluate_swap_delta(a, b, perm, A, C, N):
30@njit 31def evaluate_swap_delta(a, b, perm, A, C, N): 32 """Calculates the exact change in objective if nodes A and B swap their instance assignments.""" 33 34 p_a = perm[a] 35 p_b = perm[b] 36 37 delta = 0.0 38 39 for i in range(N): 40 41 if i == a or i == b: 42 continue 43 44 p_i = perm[i] 45 46 # Edges connected to A 47 if A[i, a] > 0: delta += A[i, a] * (C[p_i, p_b] - C[p_i, p_a]) 48 if A[a, i] > 0: delta += A[a, i] * (C[p_b, p_i] - C[p_a, p_i]) 49 50 # Edges connected to B 51 if A[i, b] > 0: delta += A[i, b] * (C[p_i, p_a] - C[p_i, p_b]) 52 if A[b, i] > 0: delta += A[b, i] * (C[p_a, p_i] - C[p_b, p_i]) 53 54 # Handle A and B connected directly to each other 55 if A[a, b] > 0: delta += A[a, b] * (C[p_b, p_a] - C[p_a, p_b]) 56 if A[b, a] > 0: delta += A[b, a] * (C[p_a, p_b] - C[p_b, p_a]) 57 58 # Handle self-loops correctly if they exist in the graph 59 if A[a, a] > 0: delta += A[a, a] * (C[p_b, p_b] - C[p_a, p_a]) 60 if A[b, b] > 0: delta += A[b, b] * (C[p_a, p_a] - C[p_b, p_b]) 61 62 return delta
Calculates the exact change in objective if nodes A and B swap their instance assignments.
@njit
def
steepest_descent(perm, A, C, N):
64@njit 65def steepest_descent(perm, A, C, N): 66 """Explores the full neighborhood and applies the steepest ascent swap until stuck.""" 67 improved = True 68 current_perm = perm.copy() 69 70 while improved: 71 improved = False 72 best_delta = 1e-9 # Must strictly improve 73 best_a = -1 74 best_b = -1 75 76 for a in range(N): 77 for b in range(a + 1, N): 78 delta = evaluate_swap_delta(a, b, current_perm, A, C, N) 79 if delta > best_delta: 80 best_delta = delta 81 best_a = a 82 best_b = b 83 84 # Apply the best move found 85 if best_a != -1: 86 tmp = current_perm[best_a] 87 current_perm[best_a] = current_perm[best_b] 88 current_perm[best_b] = tmp 89 improved = True 90 91 return current_perm
Explores the full neighborhood and applies the steepest ascent swap until stuck.
@njit
def
set_numba_seed(seed):
@njit
def
perturb(perm, N, kick_strength):
97@njit 98def perturb(perm, N, kick_strength): 99 """Executes a random walk of K valid swaps to escape local optima.""" 100 new_perm = perm.copy() 101 swaps_done = 0 102 attempts = 0 103 104 while swaps_done < kick_strength and attempts < 200: 105 attempts += 1 106 107 a = np.random.randint(0, N) 108 b = np.random.randint(0, N) 109 110 # In a pure permutation without graph state restrictions, 111 # the only invalid swap is swapping an index with itself. 112 if a == b: 113 continue 114 115 # Swap 116 tmp = new_perm[a] 117 new_perm[a] = new_perm[b] 118 new_perm[b] = tmp 119 swaps_done += 1 120 121 return new_perm
Executes a random walk of K valid swaps to escape local optima.
@njit
def
ils_engine( initial_permutation, A, C, N, max_iterations, kick_strength, random_seed=None):
123@njit 124def ils_engine(initial_permutation, A, C, N, max_iterations, kick_strength, random_seed=None): 125 126 if random_seed is not None: 127 set_numba_seed(random_seed) 128 129 best_permutation = steepest_descent(initial_permutation, A, C, N) 130 131 # Calculate baseline absolute energy (objective is maximization) 132 best_energy = 0.0 133 for i in range(N): 134 for j in range(N): 135 if A[i, j] > 0: 136 best_energy += A[i, j] * C[best_permutation[i], best_permutation[j]] 137 138 for i in range(max_iterations): 139 perturbed_perm = perturb(best_permutation, N, kick_strength) 140 candidate_perm = steepest_descent(perturbed_perm, A, C, N) 141 142 candidate_energy = 0.0 143 for x in range(N): 144 for y in range(N): 145 if A[x, y] > 0: 146 candidate_energy += A[x, y] * C[candidate_perm[x], candidate_perm[y]] 147 148 # Accept if it is a new global best 149 if candidate_energy > best_energy: 150 best_energy = candidate_energy 151 best_permutation = candidate_perm.copy() 152 153 return best_permutation, best_energy
def
optimize_node_permutation_ils( G: networkx.classes.digraph.DiGraph, cohesion_matrix: numpy.ndarray, ils_iterations: int = 5000, kick_strength: int = 4, np_rng: numpy.random._generator.Generator | None = None) -> list[int]:
155def optimize_node_permutation_ils( 156 G: nx.DiGraph, 157 cohesion_matrix: np.ndarray, 158 ils_iterations: int = 5000, 159 kick_strength: int = 4, 160 np_rng: np.random.Generator | None = None 161) -> list[int]: 162 """ 163 Optimizes the node assignment utilizing Iterated Local Search. 164 Maximizes the summed cohesion weights across the directed graph. 165 """ 166 np_rng = resolve_rng( np_rng ) 167 numba_seed = np_rng.integers(0, 2**32 - 1) 168 169 # Convert to 64-bit float for Numba compatibility 170 # (avoiding the 10_000 integer scaling needed for CP-SAT) 171 C = cohesion_matrix.astype(np.float64) 172 173 N = len(list(G.nodes())) 174 node_to_idx = {node: i for i, node in enumerate(G.nodes())} 175 176 # Build an adjacency weight matrix from the graph 177 A = np.zeros((N, N), dtype=np.float64) 178 for u, v in G.edges(): 179 i, j = node_to_idx[u], node_to_idx[v] 180 A[i, j] += 1.0 181 182 # Start from a randomized initial state 183 initial_permutation = np.arange(N, dtype=np.int32) 184 np_rng.shuffle(initial_permutation) 185 186 best_permutation, _ = ils_engine( 187 initial_permutation, 188 A, 189 C, 190 N, 191 ils_iterations, 192 kick_strength, 193 numba_seed 194 ) 195 196 print( f"ils node permuation score {_calculate_score(G,cohesion_matrix,best_permutation)}" ) 197 198 return best_permutation.tolist()
Optimizes the node assignment utilizing Iterated Local Search. Maximizes the summed cohesion weights across the directed graph.