GitLab Repo

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):
93@njit
94def set_numba_seed(seed):
95    np.random.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.