GitLab Repo

amachine.am_create.am_random_machine

  1from collections import defaultdict
  2import random
  3import warnings
  4import math
  5import numpy as np
  6
  7from ..am_hmm          import HMM
  8from ..am_causal_state import CausalState
  9from ..am_transition   import Transition
 10
 11from ..am_random import exp_uniform_blend, resolve_rng
 12from ..am_vocabulary import Vocabulary
 13
 14def random_machine( 
 15    n_states : int, 
 16    symbols  : list[str],
 17    connectedness : float,
 18    randomness : float,
 19    symbol_proportions : list[float] | None = None,
 20    stratification_categories : dict[str,int] | None = None,
 21    ensure_strongly_connected : bool = False,
 22    ensure_minimal : bool = False,
 23    np_rng : np.random.Generator | None = None ) -> HMM  :
 24
 25    np_rng = resolve_rng( np_rng )
 26
 27    states=[
 28        CausalState( name=f"{i}" )
 29        for i in range( n_states  )
 30    ]
 31
 32    n_symbols = len( symbols )
 33    transitions = []
 34    cycle_transitions = {}
 35
 36    # first create a Hamiltonian cycle
 37    if ensure_strongly_connected :
 38        cycle = np_rng.permutation( n_states )
 39        for i in range( n_states ) : 
 40            i_to = ( i + 1 ) % n_states
 41            cycle_transitions[ cycle[ i ] ] = cycle[ i_to ]
 42
 43    for state_idx, state in enumerate( states ) : 
 44
 45        n_transitions = sum( 
 46            np_rng.random() < connectedness 
 47            for _ in range( n_symbols - 1 ) 
 48        ) + 1
 49
 50        n_transitions = min( n_transitions, min( n_states, n_symbols ) )
 51        
 52        # unifilar requirement means we can't choose the cycle state a second time
 53        available = set( range( n_states ) )
 54        if ensure_strongly_connected :
 55            available = available - { cycle_transitions[ state_idx ] }
 56
 57        available = sorted( list( available ) )
 58
 59        transition_to = np_rng.choice( 
 60            available, 
 61            size=n_transitions - ensure_strongly_connected,
 62            replace=False
 63        ).tolist()
 64
 65        if ensure_strongly_connected :
 66            transition_to.append( cycle_transitions[ state_idx ] )
 67
 68        transition_probabilities = exp_uniform_blend( 
 69            n=n_transitions, 
 70            alpha=randomness,
 71            np_rng=np_rng )
 72
 73        # if symbol proportions are given we will override this
 74        # transition_symbols_indices = py_rng.sample( range( n_symbols ), n_transitions )
 75        transition_symbols_indices = np_rng.choice( 
 76            list( range( n_symbols ) ), 
 77            size=n_transitions,
 78            replace=False
 79        )
 80
 81        for i, p in enumerate( transition_probabilities ) :
 82            transitions.append(
 83                Transition(
 84                    origin_state_idx=state_idx,
 85                    target_state_idx=transition_to[ i ],
 86                    prob=p,
 87                    symbol_idx=transition_symbols_indices[ i ],
 88                    pq=None
 89                )
 90            ) 
 91
 92    if symbol_proportions is not None :
 93        
 94        # ensure proporitions sum to 1 and normalize either way
 95
 96        p_sum = sum( symbol_proportions )
 97
 98        if not math.isclose(p_sum, 1.0): 
 99            warnings.warn("Symbol proportions don't sum to 1. Normalizing.")
100
101        for i in range( len(symbol_proportions) ) :
102            symbol_proportions[ i ] /= p_sum
103
104        total_symbols_needed = len( transitions )
105        
106        if stratification_categories is not None:
107
108            category_indices = defaultdict(list)
109            category_proportions = defaultdict(float)
110
111            for i, s in enumerate(symbols) :
112                cat = stratification_categories.get( s, "uncategorized" ) 
113                category_indices[cat].append( i )
114                category_proportions[ cat ] += symbol_proportions[i]
115
116            pool = []
117            
118            for cat, cat_proporition in category_proportions.items():
119                
120                if cat_proporition <= 0.0:
121                    continue
122                
123                # number of symbols from the category we need, and the symbol indices to draw from
124                cat_quota = int( np.ceil( cat_proporition * total_symbols_needed ) )
125                cat_sym_indices = category_indices[cat]
126                
127                # distribute quota among symbols within the category
128                # based on their relative weights to each other
129                internal_weights = [ symbol_proportions[i] / cat_proporition for i in cat_sym_indices ]
130                
131                probabilities = np.array(internal_weights) / np.sum(internal_weights)
132
133                drawn_symbols = np_rng.choice(
134                    cat_sym_indices, 
135                    size=cat_quota, 
136                    replace=True, 
137                    p=probabilities
138                )
139
140                pool.extend(drawn_symbols)
141        else :
142            target_symbol_counts = [ int( np.ceil( p*total_symbols_needed ) ) for p in symbol_proportions ]
143            pool = []
144            for i, s in enumerate( symbols ) : 
145                pool = pool + target_symbol_counts[ i ] * [ i ] 
146        
147        # randomize it
148        np_rng.shuffle( pool )
149
150        for state_idx, _ in enumerate( states ) : 
151            
152            # This states transitions
153            my_tr_idxs = [ 
154                tr_idx for tr_idx, tr in enumerate( transitions )
155                if tr.origin_state_idx == state_idx 
156            ]
157
158            my_n_tr = len( my_tr_idxs )
159
160            # Track which we used
161            used = set()
162            used_indices = set()
163            
164            for i, s in enumerate( pool ) :
165                if s in used :
166                    continue
167                used.add(s)
168                used_indices.add( i )
169                if len( used ) == my_n_tr :
170                    break
171
172            # If the pool was too restrictive sample the remaining from symbols not in the pool
173            if len( used ) < my_n_tr :
174                needed = my_n_tr - len( used )
175                alternatives = list(set(range( n_symbols )) - used)
176                used.update( 
177                    np_rng.choice( alternatives, size=needed, replace=False)
178                )
179
180            # shuffle them and assign them to their transitions
181            ordered = list(used)
182            np_rng.shuffle( ordered )
183
184            for i, tr_idx in enumerate( my_tr_idxs ) :
185                transitions[ tr_idx ] = transitions[ tr_idx ].modified_deep_copy(
186                    symbol_idx = ordered[ i ]
187                )
188
189            # update the pool removing the symbols taken from it
190            pool = [ s for i, s in enumerate( pool ) if i not in used_indices ]
191
192    m = HMM( 
193        states=states,
194        transitions=transitions,
195        start_state=0,
196        alphabet=symbols.copy()
197    )
198
199    # TODO consider best approach to ensure minimal while ensuring target size
200    if ensure_minimal :
201        m.minimize( retain_names=True )
202
203    if not m.is_row_stochastic() :
204        raise Exception( "Random machine is not row stochastic" )
205
206    if not  m.is_unifilar() :
207        raise Exception( "Random machine is not unifilar" )
208
209    return m
def random_machine( n_states: int, symbols: list[str], connectedness: float, randomness: float, symbol_proportions: list[float] | None = None, stratification_categories: dict[str, int] | None = None, ensure_strongly_connected: bool = False, ensure_minimal: bool = False, np_rng: numpy.random._generator.Generator | None = None) -> amachine.am_hmm.HMM:
 15def random_machine( 
 16    n_states : int, 
 17    symbols  : list[str],
 18    connectedness : float,
 19    randomness : float,
 20    symbol_proportions : list[float] | None = None,
 21    stratification_categories : dict[str,int] | None = None,
 22    ensure_strongly_connected : bool = False,
 23    ensure_minimal : bool = False,
 24    np_rng : np.random.Generator | None = None ) -> HMM  :
 25
 26    np_rng = resolve_rng( np_rng )
 27
 28    states=[
 29        CausalState( name=f"{i}" )
 30        for i in range( n_states  )
 31    ]
 32
 33    n_symbols = len( symbols )
 34    transitions = []
 35    cycle_transitions = {}
 36
 37    # first create a Hamiltonian cycle
 38    if ensure_strongly_connected :
 39        cycle = np_rng.permutation( n_states )
 40        for i in range( n_states ) : 
 41            i_to = ( i + 1 ) % n_states
 42            cycle_transitions[ cycle[ i ] ] = cycle[ i_to ]
 43
 44    for state_idx, state in enumerate( states ) : 
 45
 46        n_transitions = sum( 
 47            np_rng.random() < connectedness 
 48            for _ in range( n_symbols - 1 ) 
 49        ) + 1
 50
 51        n_transitions = min( n_transitions, min( n_states, n_symbols ) )
 52        
 53        # unifilar requirement means we can't choose the cycle state a second time
 54        available = set( range( n_states ) )
 55        if ensure_strongly_connected :
 56            available = available - { cycle_transitions[ state_idx ] }
 57
 58        available = sorted( list( available ) )
 59
 60        transition_to = np_rng.choice( 
 61            available, 
 62            size=n_transitions - ensure_strongly_connected,
 63            replace=False
 64        ).tolist()
 65
 66        if ensure_strongly_connected :
 67            transition_to.append( cycle_transitions[ state_idx ] )
 68
 69        transition_probabilities = exp_uniform_blend( 
 70            n=n_transitions, 
 71            alpha=randomness,
 72            np_rng=np_rng )
 73
 74        # if symbol proportions are given we will override this
 75        # transition_symbols_indices = py_rng.sample( range( n_symbols ), n_transitions )
 76        transition_symbols_indices = np_rng.choice( 
 77            list( range( n_symbols ) ), 
 78            size=n_transitions,
 79            replace=False
 80        )
 81
 82        for i, p in enumerate( transition_probabilities ) :
 83            transitions.append(
 84                Transition(
 85                    origin_state_idx=state_idx,
 86                    target_state_idx=transition_to[ i ],
 87                    prob=p,
 88                    symbol_idx=transition_symbols_indices[ i ],
 89                    pq=None
 90                )
 91            ) 
 92
 93    if symbol_proportions is not None :
 94        
 95        # ensure proporitions sum to 1 and normalize either way
 96
 97        p_sum = sum( symbol_proportions )
 98
 99        if not math.isclose(p_sum, 1.0): 
100            warnings.warn("Symbol proportions don't sum to 1. Normalizing.")
101
102        for i in range( len(symbol_proportions) ) :
103            symbol_proportions[ i ] /= p_sum
104
105        total_symbols_needed = len( transitions )
106        
107        if stratification_categories is not None:
108
109            category_indices = defaultdict(list)
110            category_proportions = defaultdict(float)
111
112            for i, s in enumerate(symbols) :
113                cat = stratification_categories.get( s, "uncategorized" ) 
114                category_indices[cat].append( i )
115                category_proportions[ cat ] += symbol_proportions[i]
116
117            pool = []
118            
119            for cat, cat_proporition in category_proportions.items():
120                
121                if cat_proporition <= 0.0:
122                    continue
123                
124                # number of symbols from the category we need, and the symbol indices to draw from
125                cat_quota = int( np.ceil( cat_proporition * total_symbols_needed ) )
126                cat_sym_indices = category_indices[cat]
127                
128                # distribute quota among symbols within the category
129                # based on their relative weights to each other
130                internal_weights = [ symbol_proportions[i] / cat_proporition for i in cat_sym_indices ]
131                
132                probabilities = np.array(internal_weights) / np.sum(internal_weights)
133
134                drawn_symbols = np_rng.choice(
135                    cat_sym_indices, 
136                    size=cat_quota, 
137                    replace=True, 
138                    p=probabilities
139                )
140
141                pool.extend(drawn_symbols)
142        else :
143            target_symbol_counts = [ int( np.ceil( p*total_symbols_needed ) ) for p in symbol_proportions ]
144            pool = []
145            for i, s in enumerate( symbols ) : 
146                pool = pool + target_symbol_counts[ i ] * [ i ] 
147        
148        # randomize it
149        np_rng.shuffle( pool )
150
151        for state_idx, _ in enumerate( states ) : 
152            
153            # This states transitions
154            my_tr_idxs = [ 
155                tr_idx for tr_idx, tr in enumerate( transitions )
156                if tr.origin_state_idx == state_idx 
157            ]
158
159            my_n_tr = len( my_tr_idxs )
160
161            # Track which we used
162            used = set()
163            used_indices = set()
164            
165            for i, s in enumerate( pool ) :
166                if s in used :
167                    continue
168                used.add(s)
169                used_indices.add( i )
170                if len( used ) == my_n_tr :
171                    break
172
173            # If the pool was too restrictive sample the remaining from symbols not in the pool
174            if len( used ) < my_n_tr :
175                needed = my_n_tr - len( used )
176                alternatives = list(set(range( n_symbols )) - used)
177                used.update( 
178                    np_rng.choice( alternatives, size=needed, replace=False)
179                )
180
181            # shuffle them and assign them to their transitions
182            ordered = list(used)
183            np_rng.shuffle( ordered )
184
185            for i, tr_idx in enumerate( my_tr_idxs ) :
186                transitions[ tr_idx ] = transitions[ tr_idx ].modified_deep_copy(
187                    symbol_idx = ordered[ i ]
188                )
189
190            # update the pool removing the symbols taken from it
191            pool = [ s for i, s in enumerate( pool ) if i not in used_indices ]
192
193    m = HMM( 
194        states=states,
195        transitions=transitions,
196        start_state=0,
197        alphabet=symbols.copy()
198    )
199
200    # TODO consider best approach to ensure minimal while ensuring target size
201    if ensure_minimal :
202        m.minimize( retain_names=True )
203
204    if not m.is_row_stochastic() :
205        raise Exception( "Random machine is not row stochastic" )
206
207    if not  m.is_unifilar() :
208        raise Exception( "Random machine is not unifilar" )
209
210    return m