GitLab Repo

amachine.am_optimize.am_optimize_symbol_permutation_ils

  1from collections import defaultdict
  2
  3import numpy as np
  4from numba import njit
  5
  6from ..am_hmm import HMM
  7from ..am_structured_symbol_set import StructuredSymbolSet
  8from ..am_random import resolve_rng
  9
 10@njit
 11def evaluate_swap_delta(a, b, symbols, W, C, n_edges):
 12    """Calculates the exact change in objective if edge A and B swap symbols."""
 13    sym_a = symbols[a]
 14    sym_b = symbols[b]
 15    delta = 0.0
 16    
 17    for i in range(n_edges):
 18
 19        if i == a or i == b:
 20            continue
 21        
 22        sym_i = symbols[i]
 23        
 24        # Edges connected to A
 25        if W[i, a] > 0: delta += W[i, a] * (C[sym_i, sym_b] - C[sym_i, sym_a])
 26        if W[a, i] > 0: delta += W[a, i] * (C[sym_b, sym_i] - C[sym_a, sym_i])
 27        
 28        # Edges connected to B
 29        if W[i, b] > 0: delta += W[i, b] * (C[sym_i, sym_a] - C[sym_i, sym_b])
 30        if W[b, i] > 0: delta += W[b, i] * (C[sym_a, sym_i] - C[sym_b, sym_i])
 31
 32    # Handle A and B connected directly to each other (prevents double counting)
 33    if W[a, b] > 0: delta += W[a, b] * (C[sym_b, sym_a] - C[sym_a, sym_b])
 34    if W[b, a] > 0: delta += W[b, a] * (C[sym_a, sym_b] - C[sym_b, sym_a])
 35
 36    if W[a, a] > 0: delta += W[a, a] * (C[sym_b, sym_b] - C[sym_a, sym_a])
 37    if W[b, b] > 0: delta += W[b, b] * (C[sym_a, sym_a] - C[sym_b, sym_b])
 38
 39    return delta
 40
 41@njit
 42def steepest_descent(
 43    symbols, 
 44    W, 
 45    C, 
 46    origins, 
 47    state_out_edges, 
 48    n_edges ) :
 49
 50    """Explores the neighborhood and applies the best possible swap until stuck."""
 51    
 52    improved = True
 53    current_symbols = symbols.copy()
 54    
 55    while improved:
 56        improved = False
 57        best_delta = 1e-9  # Must strictly improve
 58        best_a = -1
 59        best_b = -1
 60        
 61        for a in range(n_edges):
 62            for b in range(a + 1, n_edges):
 63                if current_symbols[a] == current_symbols[b]:
 64                    continue
 65                    
 66                # unifilarity check
 67                orig_a = origins[a]
 68                orig_b = origins[b]
 69
 70                if orig_a != orig_b:
 71                
 72                    conflict = False
 73                
 74                    for e in state_out_edges[orig_a]:
 75                        if e != -1 and e != a and current_symbols[e] == current_symbols[b]:
 76                            conflict = True
 77                            break
 78                
 79                    if not conflict:
 80                        for e in state_out_edges[orig_b]:
 81                            if e != -1 and e != b and current_symbols[e] == current_symbols[a]:
 82                                conflict = True
 83                                break
 84                
 85                    if conflict:
 86                        continue
 87                        
 88                delta = evaluate_swap_delta(a, b, current_symbols, W, C, n_edges)
 89                if delta > best_delta:
 90                    best_delta = delta
 91                    best_a = a
 92                    best_b = b
 93                    
 94        # Apply the best move found
 95        if best_a != -1:
 96            tmp = current_symbols[best_a]
 97            current_symbols[best_a] = current_symbols[best_b]
 98            current_symbols[best_b] = tmp
 99            improved = True
100            
101    return current_symbols
102
103@njit
104def set_numba_seed( seed ):
105    np.random.seed( seed )
106
107@njit
108def perturb(
109    symbols, 
110    origins, 
111    state_out_edges, 
112    n_edges, 
113    kick_strength ) :
114
115    """Executes a random walk of K valid swaps to escape local optima."""
116
117    new_symbols = symbols.copy()
118    swaps_done = 0
119    attempts = 0
120
121    while swaps_done < kick_strength and attempts < 200:
122        
123        attempts += 1
124
125        a = np.random.randint(0, n_edges)
126        b = np.random.randint(0, n_edges)
127        
128        if a == b or new_symbols[a] == new_symbols[b]:
129            continue
130            
131        orig_a = origins[a]
132        orig_b = origins[b]
133
134        if orig_a != orig_b:
135        
136            conflict = False
137        
138            for e in state_out_edges[orig_a]:
139                if e != -1 and e != a and new_symbols[e] == new_symbols[b]:
140                    conflict = True; break
141        
142            if not conflict:
143                for e in state_out_edges[orig_b]:
144                    if e != -1 and e != b and new_symbols[e] == new_symbols[a]:
145                        conflict = True; break
146           
147            if conflict:
148                continue
149                
150        # Swap
151        tmp = new_symbols[a]
152        new_symbols[a] = new_symbols[b]
153        new_symbols[b] = tmp
154        swaps_done += 1
155        
156    return new_symbols
157
158@njit
159def ils_engine(
160    initial_symbols, 
161    W, 
162    C, 
163    origins, 
164    state_out_edges, 
165    n_edges, 
166    max_iterations, 
167    kick_strength,
168    random_seed=None ) :
169
170    if random_seed is not None :
171        set_numba_seed( random_seed )
172
173    best_symbols = steepest_descent(initial_symbols, W, C, origins, state_out_edges, n_edges)
174    
175    # Calculate baseline absolute energy
176    best_energy = 0.0
177    for i in range(n_edges):
178        for j in range(n_edges):
179            if W[i, j] > 0:
180                best_energy += W[i, j] * C[best_symbols[i], best_symbols[j]]
181                
182    for i in range(max_iterations):
183
184        # Kick from the best known solution
185        perturbed_symbols = perturb(
186            best_symbols, 
187            origins, 
188            state_out_edges, 
189            n_edges, 
190            kick_strength
191        )
192        
193        # Plunge back down to a local optimum
194        candidate_symbols = steepest_descent(
195            perturbed_symbols, 
196            W, 
197            C, 
198            origins, 
199            state_out_edges, 
200            n_edges 
201        )
202        
203        # Evaluate total energy of new local optimum
204        candidate_energy = 0.0
205        for x in range(n_edges):
206            for y in range(n_edges):
207                if W[x, y] > 0:
208                    candidate_energy += W[x, y] * C[candidate_symbols[x], candidate_symbols[y]]
209                    
210        # Accept if it's a new global best
211        if candidate_energy > best_energy:
212            best_energy = candidate_energy
213            best_symbols = candidate_symbols.copy()
214            
215    return best_symbols, best_energy
216
217def optimize_symbol_permutation_ils(
218    m, 
219    symbol_set, 
220    frequency_bias, 
221    ils_iterations=5000, 
222    kick_strength=4,
223    np_rng=None
224):
225
226    rng=resolve_rng( np_rng )
227    numba_seed = rng.integers(0, 2**32 - 1)
228
229    transitions = m.transitions
230    n_edges = len(transitions)
231    n_states = len(m.states)
232
233    state_trs_in = defaultdict(list)
234    state_trs_out = defaultdict(list)
235    for i, tr in enumerate(transitions):
236        state_trs_in[tr.target_state_idx].append((i, tr))
237        state_trs_out[tr.origin_state_idx].append((i, tr))
238
239    max_out_degree = max((len(edges) for edges in state_trs_out.values()), default=0)
240    state_out_edges = np.full((n_states, max_out_degree), -1, dtype=np.int32)
241    origins = np.zeros(n_edges, dtype=np.int32)
242    
243    for state_idx, edges in state_trs_out.items():
244        for col_idx, (edge_idx, _) in enumerate(edges):
245            state_out_edges[state_idx, col_idx] = edge_idx
246            origins[edge_idx] = state_idx
247
248    pi = m.get_stationary_distribution()
249    tr_pair_probs = {}
250    
251    for state_idx in range(n_states):
252        for i_in, t_in in state_trs_in[state_idx]:
253            in_prob = pi[t_in.origin_state_idx] * t_in.prob
254            for i_out, t_out in state_trs_out[state_idx]:
255                tr_pair_probs[(i_in, i_out)] = in_prob * t_out.prob
256
257    n_pairs = len(tr_pair_probs)
258    uniform_w = 1.0 / n_pairs if n_pairs > 0 else 0.0
259    
260    W = np.zeros((n_edges, n_edges), dtype=np.float64)
261    for (i_in, i_out), p in tr_pair_probs.items():
262        w = (1.0 - frequency_bias) * uniform_w + frequency_bias * p
263        if w > 0:
264            W[i_in, i_out] = w
265
266    C = symbol_set.symbol_cohesion.astype(np.float64)
267    initial_symbols = np.array([t.symbol_idx for t in transitions], dtype=np.int32)
268
269    best_symbols, best_energy = ils_engine(
270        initial_symbols, 
271        W, 
272        C, 
273        origins, 
274        state_out_edges, 
275        n_edges, 
276        ils_iterations, 
277        kick_strength,
278        numba_seed
279    )
280    
281    return best_symbols.tolist()
@njit
def evaluate_swap_delta(a, b, symbols, W, C, n_edges):
11@njit
12def evaluate_swap_delta(a, b, symbols, W, C, n_edges):
13    """Calculates the exact change in objective if edge A and B swap symbols."""
14    sym_a = symbols[a]
15    sym_b = symbols[b]
16    delta = 0.0
17    
18    for i in range(n_edges):
19
20        if i == a or i == b:
21            continue
22        
23        sym_i = symbols[i]
24        
25        # Edges connected to A
26        if W[i, a] > 0: delta += W[i, a] * (C[sym_i, sym_b] - C[sym_i, sym_a])
27        if W[a, i] > 0: delta += W[a, i] * (C[sym_b, sym_i] - C[sym_a, sym_i])
28        
29        # Edges connected to B
30        if W[i, b] > 0: delta += W[i, b] * (C[sym_i, sym_a] - C[sym_i, sym_b])
31        if W[b, i] > 0: delta += W[b, i] * (C[sym_a, sym_i] - C[sym_b, sym_i])
32
33    # Handle A and B connected directly to each other (prevents double counting)
34    if W[a, b] > 0: delta += W[a, b] * (C[sym_b, sym_a] - C[sym_a, sym_b])
35    if W[b, a] > 0: delta += W[b, a] * (C[sym_a, sym_b] - C[sym_b, sym_a])
36
37    if W[a, a] > 0: delta += W[a, a] * (C[sym_b, sym_b] - C[sym_a, sym_a])
38    if W[b, b] > 0: delta += W[b, b] * (C[sym_a, sym_a] - C[sym_b, sym_b])
39
40    return delta

Calculates the exact change in objective if edge A and B swap symbols.

@njit
def steepest_descent(symbols, W, C, origins, state_out_edges, n_edges):
 42@njit
 43def steepest_descent(
 44    symbols, 
 45    W, 
 46    C, 
 47    origins, 
 48    state_out_edges, 
 49    n_edges ) :
 50
 51    """Explores the neighborhood and applies the best possible swap until stuck."""
 52    
 53    improved = True
 54    current_symbols = symbols.copy()
 55    
 56    while improved:
 57        improved = False
 58        best_delta = 1e-9  # Must strictly improve
 59        best_a = -1
 60        best_b = -1
 61        
 62        for a in range(n_edges):
 63            for b in range(a + 1, n_edges):
 64                if current_symbols[a] == current_symbols[b]:
 65                    continue
 66                    
 67                # unifilarity check
 68                orig_a = origins[a]
 69                orig_b = origins[b]
 70
 71                if orig_a != orig_b:
 72                
 73                    conflict = False
 74                
 75                    for e in state_out_edges[orig_a]:
 76                        if e != -1 and e != a and current_symbols[e] == current_symbols[b]:
 77                            conflict = True
 78                            break
 79                
 80                    if not conflict:
 81                        for e in state_out_edges[orig_b]:
 82                            if e != -1 and e != b and current_symbols[e] == current_symbols[a]:
 83                                conflict = True
 84                                break
 85                
 86                    if conflict:
 87                        continue
 88                        
 89                delta = evaluate_swap_delta(a, b, current_symbols, W, C, n_edges)
 90                if delta > best_delta:
 91                    best_delta = delta
 92                    best_a = a
 93                    best_b = b
 94                    
 95        # Apply the best move found
 96        if best_a != -1:
 97            tmp = current_symbols[best_a]
 98            current_symbols[best_a] = current_symbols[best_b]
 99            current_symbols[best_b] = tmp
100            improved = True
101            
102    return current_symbols

Explores the neighborhood and applies the best possible swap until stuck.

@njit
def set_numba_seed(seed):
104@njit
105def set_numba_seed( seed ):
106    np.random.seed( seed )
@njit
def perturb(symbols, origins, state_out_edges, n_edges, kick_strength):
108@njit
109def perturb(
110    symbols, 
111    origins, 
112    state_out_edges, 
113    n_edges, 
114    kick_strength ) :
115
116    """Executes a random walk of K valid swaps to escape local optima."""
117
118    new_symbols = symbols.copy()
119    swaps_done = 0
120    attempts = 0
121
122    while swaps_done < kick_strength and attempts < 200:
123        
124        attempts += 1
125
126        a = np.random.randint(0, n_edges)
127        b = np.random.randint(0, n_edges)
128        
129        if a == b or new_symbols[a] == new_symbols[b]:
130            continue
131            
132        orig_a = origins[a]
133        orig_b = origins[b]
134
135        if orig_a != orig_b:
136        
137            conflict = False
138        
139            for e in state_out_edges[orig_a]:
140                if e != -1 and e != a and new_symbols[e] == new_symbols[b]:
141                    conflict = True; break
142        
143            if not conflict:
144                for e in state_out_edges[orig_b]:
145                    if e != -1 and e != b and new_symbols[e] == new_symbols[a]:
146                        conflict = True; break
147           
148            if conflict:
149                continue
150                
151        # Swap
152        tmp = new_symbols[a]
153        new_symbols[a] = new_symbols[b]
154        new_symbols[b] = tmp
155        swaps_done += 1
156        
157    return new_symbols

Executes a random walk of K valid swaps to escape local optima.

@njit
def ils_engine( initial_symbols, W, C, origins, state_out_edges, n_edges, max_iterations, kick_strength, random_seed=None):
159@njit
160def ils_engine(
161    initial_symbols, 
162    W, 
163    C, 
164    origins, 
165    state_out_edges, 
166    n_edges, 
167    max_iterations, 
168    kick_strength,
169    random_seed=None ) :
170
171    if random_seed is not None :
172        set_numba_seed( random_seed )
173
174    best_symbols = steepest_descent(initial_symbols, W, C, origins, state_out_edges, n_edges)
175    
176    # Calculate baseline absolute energy
177    best_energy = 0.0
178    for i in range(n_edges):
179        for j in range(n_edges):
180            if W[i, j] > 0:
181                best_energy += W[i, j] * C[best_symbols[i], best_symbols[j]]
182                
183    for i in range(max_iterations):
184
185        # Kick from the best known solution
186        perturbed_symbols = perturb(
187            best_symbols, 
188            origins, 
189            state_out_edges, 
190            n_edges, 
191            kick_strength
192        )
193        
194        # Plunge back down to a local optimum
195        candidate_symbols = steepest_descent(
196            perturbed_symbols, 
197            W, 
198            C, 
199            origins, 
200            state_out_edges, 
201            n_edges 
202        )
203        
204        # Evaluate total energy of new local optimum
205        candidate_energy = 0.0
206        for x in range(n_edges):
207            for y in range(n_edges):
208                if W[x, y] > 0:
209                    candidate_energy += W[x, y] * C[candidate_symbols[x], candidate_symbols[y]]
210                    
211        # Accept if it's a new global best
212        if candidate_energy > best_energy:
213            best_energy = candidate_energy
214            best_symbols = candidate_symbols.copy()
215            
216    return best_symbols, best_energy
def optimize_symbol_permutation_ils( m, symbol_set, frequency_bias, ils_iterations=5000, kick_strength=4, np_rng=None):
218def optimize_symbol_permutation_ils(
219    m, 
220    symbol_set, 
221    frequency_bias, 
222    ils_iterations=5000, 
223    kick_strength=4,
224    np_rng=None
225):
226
227    rng=resolve_rng( np_rng )
228    numba_seed = rng.integers(0, 2**32 - 1)
229
230    transitions = m.transitions
231    n_edges = len(transitions)
232    n_states = len(m.states)
233
234    state_trs_in = defaultdict(list)
235    state_trs_out = defaultdict(list)
236    for i, tr in enumerate(transitions):
237        state_trs_in[tr.target_state_idx].append((i, tr))
238        state_trs_out[tr.origin_state_idx].append((i, tr))
239
240    max_out_degree = max((len(edges) for edges in state_trs_out.values()), default=0)
241    state_out_edges = np.full((n_states, max_out_degree), -1, dtype=np.int32)
242    origins = np.zeros(n_edges, dtype=np.int32)
243    
244    for state_idx, edges in state_trs_out.items():
245        for col_idx, (edge_idx, _) in enumerate(edges):
246            state_out_edges[state_idx, col_idx] = edge_idx
247            origins[edge_idx] = state_idx
248
249    pi = m.get_stationary_distribution()
250    tr_pair_probs = {}
251    
252    for state_idx in range(n_states):
253        for i_in, t_in in state_trs_in[state_idx]:
254            in_prob = pi[t_in.origin_state_idx] * t_in.prob
255            for i_out, t_out in state_trs_out[state_idx]:
256                tr_pair_probs[(i_in, i_out)] = in_prob * t_out.prob
257
258    n_pairs = len(tr_pair_probs)
259    uniform_w = 1.0 / n_pairs if n_pairs > 0 else 0.0
260    
261    W = np.zeros((n_edges, n_edges), dtype=np.float64)
262    for (i_in, i_out), p in tr_pair_probs.items():
263        w = (1.0 - frequency_bias) * uniform_w + frequency_bias * p
264        if w > 0:
265            W[i_in, i_out] = w
266
267    C = symbol_set.symbol_cohesion.astype(np.float64)
268    initial_symbols = np.array([t.symbol_idx for t in transitions], dtype=np.int32)
269
270    best_symbols, best_energy = ils_engine(
271        initial_symbols, 
272        W, 
273        C, 
274        origins, 
275        state_out_edges, 
276        n_edges, 
277        ils_iterations, 
278        kick_strength,
279        numba_seed
280    )
281    
282    return best_symbols.tolist()