GitLab Repo

amachine.am_hmm

   1from __future__ import annotations
   2
   3from types import MappingProxyType
   4from typing import Any
   5import copy
   6from collections import defaultdict
   7from collections.abc import Sequence
   8import json
   9from fractions import Fraction
  10from pathlib import Path
  11import warnings
  12import time
  13import itertools
  14
  15import networkx as nx
  16from automata.fa.dfa import DFA
  17
  18import sympy
  19import numpy as np
  20
  21from amachine.am_visualization.am_histogram import index_histogram
  22
  23from .am_msp import MSP, compute_msp, compute_msp_exact
  24from .am_solve import solve_for_pi, solve_for_pi_fractional
  25
  26from . import am_fast
  27
  28from .am_causal_state import CausalState
  29from .am_transition   import Transition
  30
  31class HMM :
  32
  33    """Hidden Markov model implementing epsilon machines, mixed state presentations,
  34    complexity measures, and data generation.
  35
  36    Args:
  37        states (list[CausalState] | None): A list of causal states.
  38        transitions (list[Transition] | None): A list of transitions between states.
  39        start_state (int): Index of the start state.
  40        alphabet (list[str]): List of symbols making up the alphabet.
  41        name (str): Name of the model.
  42        description (str): Description of the model.
  43    """
  44
  45    _id_generator = itertools.count(1)
  46
  47    def __init__( 
  48        self,
  49        states      : Sequence[CausalState],
  50        transitions : Sequence[Transition],
  51        alphabet    : Sequence[str],
  52        start_state : int = 0,
  53        composition_depth : int = 0,
  54        name        : str = "",
  55        description : str = "" ) : 
  56
  57        self.unique_id = next(HMM._id_generator)
  58
  59        # MappingProxyType offers immutable view of dict
  60        self.symbol_idx_map : MappingProxyType = MappingProxyType( {} )
  61        self.state_idx_map  : MappingProxyType = MappingProxyType( {} )
  62
  63        self.alphabet    = tuple( alphabet    )   
  64        self.states      = tuple( states      )
  65        self.transitions = tuple( transitions )
  66
  67        self.update_symbol_idx_map()
  68        self.update_state_idx_map()
  69
  70        self.name : str = name
  71        self.description : str = description
  72
  73        self.composition_depth = composition_depth
  74
  75        self.start_state : int = start_state
  76
  77        # --- derived --------
  78
  79        self._complexity : dict[str, Any] = {}
  80        self._pi_fractional = None
  81        self._pi : np.ndarray | None = None
  82        
  83        self._T : np.ndarray | None = None
  84        self._T_x  : list[np.ndarray] | None = None
  85        self._msp : MSP | None = None
  86
  87        self._reverse_am : HMM | None = None
  88        self._has_valid_rational_probabilities : bool = False
  89        self._already_minimized : bool = False
  90
  91        # --- const ----------
  92
  93        self._EPS : float = 1e-12
  94
  95    #-------------------------------------------------------#
  96    #             Setters and State Management              #
  97    #-------------------------------------------------------#
  98    
  99    def clear_cache(self) :
 100
 101        """
 102        Reset all derived properties so they will be recomputed when requested later.
 103        """
 104
 105        self._complexity = {}
 106        self._T = None
 107        self._T_x = None
 108        self._pi = None
 109        self._pi_fractional = None
 110        self._msp = None 
 111        self._reverse_am = None
 112
 113        self._has_valid_rational_probabilities = False
 114        self._already_minimized = False
 115
 116    def update_symbol_idx_map(self) :
 117        new_idx_map = {}
 118        for idx, symbol in enumerate( self.alphabet ) :
 119            new_idx_map[ symbol ] = idx
 120        self.symbol_idx_map = MappingProxyType( new_idx_map )
 121
 122    def update_state_idx_map(self) :
 123        new_idx_map = {}
 124        for idx, state in enumerate( self.states ) :
 125            new_idx_map[ state.name ] = idx
 126        self.state_idx_map = MappingProxyType( new_idx_map )
 127
 128    def set_states( self, states : Sequence[CausalState] ) :
 129        self.clear_cache()        
 130        self.states = tuple( states )
 131        self.update_state_idx_map()
 132
 133    def set_alphabet( self, alphabet : Sequence[str] ) :
 134
 135        self.clear_cache()
 136
 137        old_alphabet = tuple( self.alphabet )
 138
 139        self.alphabet = tuple( sorted( set( alphabet ) ) )
 140        self.update_symbol_idx_map()
 141
 142        new_transitions = []
 143        for i, tr in enumerate( self.transitions ) :
 144            symbol = old_alphabet[ tr.symbol_idx ]
 145            new_transitions.append(  
 146                tr.modified_deep_copy(
 147                    symbol_idx=self.symbol_idx_map[ symbol ]
 148                )
 149            )
 150
 151        self.set_transitions( new_transitions )
 152
 153    def set_transitions( self, transitions : Sequence[Transition] ) :
 154        self.clear_cache()
 155        self.transitions = tuple( transitions )
 156
 157    def get_complexity_measure_if_exists(self, measure ) :
 158        m = self._complexity.get( measure, None )
 159        return m
 160
 161    def set_complexity_measure(self, measure, value ) :
 162        self._complexity[ measure ] = value
 163
 164    #-------------------------------------------------------#
 165    #    So that MappingProxyType doesn't breaks deepcopy   #
 166    #-------------------------------------------------------#
 167
 168    def __getstate__( self ) :
 169        state = self.__dict__.copy()
 170        state[ 'symbol_idx_map' ] = dict( self.symbol_idx_map )
 171        state[ 'state_idx_map'  ] = dict( self.state_idx_map  )
 172        return state
 173
 174    def __setstate__( self, state ) :
 175        state[ 'symbol_idx_map' ] = MappingProxyType( state[ 'symbol_idx_map' ] )
 176        state[ 'state_idx_map'  ] = MappingProxyType( state[ 'state_idx_map'  ] )
 177        self.__dict__.update( state )
 178
 179    #-------------------------------------------------------#
 180    #                     Properties                        #
 181    #-------------------------------------------------------#
 182
 183    @property
 184    def is_q_weighted(self) :
 185        return self._has_valid_rational_probabilities
 186
 187    #-------------------------------------------------------#
 188    #                     Serialization                     #
 189    #-------------------------------------------------------#
 190
 191    def from_json_dict( self, config : dict[str, Any] ) :
 192
 193        self.name             = config.get( "name", "" )
 194        self.description      = config.get( "description", "" )
 195        self.start_state     = config.get( "start_state", 0 )
 196        
 197        json_states      = config.get( "states",      [] )
 198        json_transitions = config.get( "transitions", [] )
 199
 200        states=[ 
 201            CausalState.from_json_dict( state )
 202            for state in json_states
 203        ]
 204
 205        transitions=[ 
 206            Transition.from_json_dict( tr )
 207            for tr in json_transitions
 208        ]
 209
 210        self.alphabet = config.get( "alphabet", () )
 211
 212        self.states      = tuple( states )
 213        self.transitions = tuple( transitions )
 214
 215        self.update_symbol_idx_map()
 216        self.update_state_idx_map()
 217
 218    def to_json_dict(self) -> dict[ str, Any ]:
 219
 220        """Create a dict representing the HMM configuration.
 221
 222        Returns:
 223
 224            dict[str,any]: Dictionary containing, name, description, states, transitions, alphabet.
 225        """
 226
 227        return {
 228            "name"            : self.name,
 229            "description"     : self.description,
 230            "start_state"     : self.start_state,
 231            "states"          : [ state.to_json_dict()      for state      in self.states      ],
 232            "transitions"     : [ transition.to_json_dict() for transition in self.transitions ],
 233            "alphabet"        : list(self.alphabet)
 234        }
 235
 236    def save_config(
 237        self, 
 238        output_dir : Path | str, 
 239        with_complexity : bool = False, 
 240        with_non_trivial_complexity : bool = False ) :
 241
 242        output_dir = Path( output_dir )
 243
 244        config = self.to_json_dict()
 245
 246        if with_complexity :
 247            
 248            complexity = self.get_complexities( 
 249                with_non_trivial=with_non_trivial_complexity
 250            )
 251
 252            config[ "complexity" ] = complexity
 253
 254        config[ "structural_properties" ] = {
 255            "unifilar"              : self.is_unifilar(),
 256            "row_stochastic"        : self.is_row_stochastic(),
 257            "strongly_connected"    : self.is_strongly_connected(),
 258            "aperiodic"             : self.is_aperiodic() #,
 259            # "minimal"               : self._is_minimal_as_dfa( topological_only=False )
 260        }
 261
 262        with open( output_dir / "am_config.json", "w", encoding="utf-8" ) as f :
 263            json.dump( config, f, ensure_ascii=False, indent=2, default=list )
 264
 265    def from_file( self, path : Path ) :
 266        with open( path / "am_config.json", "r" ) as f:
 267            config = json.load(f)
 268        self.from_json_dict( config )
 269
 270    #-------------------------------------------------------#
 271    #                   Get and Compute                     #
 272    #-------------------------------------------------------#
 273
 274    def get_transition_list(self) -> list[list[tuple[int, float, int]]] :
 275        trs = [ [] for _ in range( len( self.states ) ) ]
 276        for tr in self.transitions :
 277            trs[ tr.origin_state_idx ].append( ( 
 278                tr.symbol_idx, 
 279                float( tr.prob ),
 280                tr.target_state_idx ) )
 281        return trs
 282
 283    def get_complexities( 
 284        self, 
 285        with_non_trivial=False ) :
 286
 287        trivial = [
 288            self.C_mu,
 289            self.h_mu,
 290            self.H_1,
 291            self.rho_mu
 292        ]
 293
 294        non_trivial = [
 295            self.E, 
 296            self.T_inf,
 297            self.S,
 298            self.chi
 299        ]
 300            
 301        complexities = { m.__name__ : m() for m in trivial }
 302
 303        if with_non_trivial :
 304
 305            complexities |= { m.__name__ : float( m() ) for m in non_trivial }
 306
 307            _ = self.block_convergence()
 308
 309            scalar_complexity_keys = {
 310                "E",    
 311                "S",    
 312                "T_inf"
 313            }
 314
 315            complexities[ "block" ] = {}
 316            for key in scalar_complexity_keys :
 317                c = self.get_complexity_measure_if_exists( key )
 318                if c is not None :
 319                    complexities[ "block" ][ key ] = float( c )
 320
 321            block_complexity_keys = {
 322                "E_L",   
 323                "T_L",   
 324                "S_L",   
 325                "H_L",   
 326                "h_mu_L",
 327                "H_sync"
 328            }
 329
 330            for key in block_complexity_keys :
 331                bc = self.get_complexity_measure_if_exists( key )
 332                if bc is not None :
 333                    complexities[ "block" ][ key ] = [ float(x) for x in bc ]
 334
 335        return complexities
 336
 337    #-------------------------------------------------------#
 338
 339    def get_metadata(self) :
 340
 341        C = self.get_complexities( with_non_trivial=False )
 342
 343        return {
 344            "name" : self.name,
 345            'complexity'  : self._complexity,
 346            "description" : self.description
 347        }
 348
 349    def get_transition_matrix(self) :
 350
 351        if self._T  is not None :
 352            return self._T
 353
 354        n_states = len( self.states )
 355        T = np.zeros((n_states, n_states))
 356
 357        for tr in self.transitions :    
 358            T[ tr.origin_state_idx, tr.target_state_idx  ] = tr.prob
 359
 360        self._T = T
 361
 362        return self._T
 363
 364    #-------------------------------------------------------#
 365
 366    def get_T_X(self) :
 367
 368        if self._T_x  is not None :
 369            return self._T_x
 370
 371        n_states  = len( self.states )
 372        n_symbols = len( self.alphabet )
 373
 374        T_x = [ np.zeros( ( n_states, n_states) ) for _ in range( n_symbols ) ]
 375
 376        for tr in self.transitions :
 377            T_x[ tr.symbol_idx ][tr.origin_state_idx, tr.target_state_idx] = tr.prob
 378
 379        self._T_x = T_x
 380        return self._T_x
 381
 382    #-------------------------------------------------------#
 383
 384    def get_msp_qw(
 385        self,
 386        exact_state_cap: int = 1000,
 387        verbose: bool = True,
 388    ) -> MSP :
 389        if self._msp is not None:
 390            return self._msp
 391
 392        try : 
 393
 394            print( "\nTrying to Compute Mixed State Presentation using Exact Fractions\n" )
 395
 396            self._msp = compute_msp_exact(
 397                T_x=self.get_Tx_fractional(),
 398                pi=self.get_fractional_stationary_distribution(),
 399                n_states=len(self.states),
 400                alphabet=self.alphabet,
 401                exact_state_cap=exact_state_cap,
 402                verbose=verbose
 403            )
 404
 405            return self._msp 
 406
 407        except RuntimeError as e :
 408            warnings.warn( f"Exact msp failed: {e} Falling back to msp approximation." )
 409
 410        return self.get_msp()
 411
 412    def get_msp(
 413        self,
 414        exact_state_cap: int = 1_250_000,
 415        verbose = True,
 416    ) -> MSP :
 417
 418        if self._msp is not None:
 419            return self._msp
 420     
 421        T_x = self.get_T_X()
 422        pi  = self.get_stationary_distribution()
 423    
 424        print( "\nComputing Mixed State Presentation..." )
 425
 426        self._msp = compute_msp( 
 427            T_x=T_x,
 428            pi=pi,
 429            n_states=len(self.states),
 430            alphabet=self.alphabet,
 431            exact_state_cap=exact_state_cap,
 432            verbose=verbose
 433        )
 434
 435        return self._msp
 436
 437    def get_reverse_am(self) :
 438
 439        was_q_weighted = self._has_valid_rational_probabilities
 440
 441        if self._reverse_am is not None:
 442            return self._reverse_am
 443
 444        pi = self.get_stationary_distribution()
 445        self._reverse_am = copy.deepcopy(self)
 446        
 447        new_transitions = []
 448        for tr in self.transitions:
 449            i = tr.target_state_idx
 450            j = tr.origin_state_idx
 451            
 452            p_reversed = (pi[j] * tr.prob) / pi[i]
 453            
 454            new_transitions.append(
 455                tr.modified_deep_copy(
 456                    origin_state_idx=i,
 457                    target_state_idx=j,
 458                    prob=p_reversed,
 459                    pq=None
 460                )
 461            )
 462
 463        self._reverse_am.set_transitions(new_transitions)
 464
 465        if self._reverse_am.is_epsilon_machine():
 466            return self._reverse_am
 467
 468        rmsp = self._reverse_am.get_msp_qw( exact_state_cap=len(self.states)*4 )
 469
 470        self._reverse_am.set_states( rmsp.states )
 471        self._reverse_am.set_transitions( rmsp.transitions )
 472        self._reverse_am._msp = rmsp
 473        self._reverse_am.start_state = 0
 474
 475        self._reverse_am.collapse_to_largest_strongly_connected_subgraph()
 476        self._reverse_am.minimize()
 477
 478        if was_q_weighted :
 479            self._reverse_am.to_q_weighted()
 480
 481        return self._reverse_am
 482
 483    #-------------------------------------------------------#
 484
 485    def get_Tx_fractional(self) -> list[ list[ list[ Fraction ] ] ] :
 486
 487        self.to_q_weighted()
 488
 489        n_states  = len( self.states )
 490        n_symbols = len( self.alphabet )
 491
 492        T_x = []
 493
 494        for x in range( n_symbols ) :
 495            T_x.append( [] )
 496            for i in range( n_states ) :
 497                T_x[ x ].append( [ 0 for _ in range( n_states ) ] )
 498
 499        for tr in self.transitions :
 500            T_x[ tr.symbol_idx ][ tr.origin_state_idx ][ tr.target_state_idx ] = tr.pq
 501
 502        return T_x
 503
 504    def get_T_sympy( self ) :
 505
 506        self.to_q_weighted()
 507
 508        n = len( self.states )
 509        T = sympy.zeros( n, n )
 510
 511        for tr in self.transitions :
 512            T[ tr.origin_state_idx, tr.target_state_idx ] = tr.pq
 513
 514        return T
 515
 516    def get_fractional_stationary_distribution(self) :
 517
 518        T = self.get_T_sympy()
 519
 520        if self._pi_fractional is not None :
 521            return self._pi_fractional
 522
 523        G = self.as_digraph()
 524
 525        if not nx.is_strongly_connected(G):
 526            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
 527
 528        self._pi_fractional = solve_for_pi_fractional( T )
 529
 530        return self._pi_fractional
 531
 532    def get_stationary_distribution(self):
 533
 534        if self._pi is not None :
 535            return self._pi
 536
 537        G = self.as_digraph()
 538        
 539        if not nx.is_strongly_connected(G):
 540            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
 541
 542        T = self.get_transition_matrix()
 543        return solve_for_pi( T )		
 544
 545    #-------------------------------------------------------#
 546    #                 Complexity Measures                   #
 547    #-------------------------------------------------------#
 548    
 549    def C_mu( self ) :
 550
 551        """The *statistical complexity* (aka *forecasting complexity*) :
 552
 553        .. math::
 554
 555            C_{\\mu} = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\log_2 \\Pr(\\sigma),
 556
 557        where :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
 558
 559        .. note::
 560
 561            **Interpretations**
 562
 563            * The amount of historical information a process stores.
 564            * The amount of structure in a process.
 565
 566        Returns:
 567
 568            float: :math:`C_{\\mu}`.
 569
 570        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 571            Decomposition of Intrinsic Computation*, 2016.
 572            <https://arxiv.org/abs/1309.3792>
 573        """
 574
 575        m = self.get_complexity_measure_if_exists( "C_mu" )
 576
 577        if m is not None :
 578            return m
 579
 580        pi = self.get_stationary_distribution()
 581
 582        h = 0
 583        for i, pr in enumerate( pi ) :
 584            
 585            if pr < self._EPS :
 586                continue
 587
 588            h += -pr * np.log2( pr )
 589
 590        self.set_complexity_measure( "C_mu", h )
 591
 592        return h
 593
 594    #-------------------------------------------------------#
 595
 596    def h_mu( self ) :
 597
 598        """The *entropy rate* :
 599
 600        .. math::
 601
 602            h_{\\mu}(\\boldsymbol{\\mathcal{S}}) = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\sum_{x \\in \\mathcal{A}} \\Pr(x|\\sigma) \\log_2 \\Pr(x|\\sigma),
 603
 604        where :math:`\\mathcal{A}` is the alphabet and :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
 605
 606        .. note::
 607
 608            **Interpretations**
 609
 610            * The lower bound on achievable loss in bits. 
 611            * The irreducable randomness in the process.
 612            * The intrinsic Randomness in the process.
 613
 614        Returns:
 615            
 616            float: :math:`h_{\\mu}`.
 617
 618        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 619            Decomposition of Intrinsic Computation*, 2016.
 620            <https://arxiv.org/abs/1309.3792>
 621        """
 622
 623        m = self.get_complexity_measure_if_exists( "h_mu" )
 624
 625        if m is not None :
 626            return m
 627
 628        T  = self.get_transition_matrix()
 629        pi = self.get_stationary_distribution()
 630
 631        n_states = pi.size
 632
 633        h = 0
 634        for i, pr in enumerate( pi ) :
 635
 636            if pr < self._EPS :
 637                continue
 638
 639            row_entropy = 0
 640            for j in range( len( pi ) ) :
 641
 642                if T[ i, j ]  < self._EPS :
 643                    continue
 644
 645                row_entropy -= T[ i, j ] * np.log2( T[ i, j ] )
 646
 647            h += pr * row_entropy
 648
 649        self.set_complexity_measure( "h_mu", h )
 650
 651        return h
 652
 653    #-------------------------------------------------------#
 654
 655    def H_1(self) -> float :
 656
 657        """The *single symbol uncertainty*:
 658
 659        .. math::
 660
 661            H(1)=-\\sum_{x\\in\\mathcal{A}} \\Pr(x) \\log_2{\\Pr(x)},
 662
 663        where :math:`\\mathcal{A}` is the alphabet [^James_2018], p.2.
 664
 665        .. note::
 666
 667            **Interpretations**
 668
 669            * How uncertain you are on average about a single measurement with no context.
 670
 671        Returns:
 672
 673            float: :math:`H(1)`.
 674
 675        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
 676            <https://arxiv.org/abs/1105.2988>
 677        """
 678
 679        m = self.get_complexity_measure_if_exists("H_1")
 680        if m is not None:
 681            return m
 682
 683        pi  = self.get_stationary_distribution()
 684        T_X = self.get_T_X()  # dict: symbol -> matrix
 685
 686        h = 0.0
 687        for T_x in T_X:
 688            # Pr(x) = sum_i pi[i] * sum_j T^(x)[i,j]
 689            p_sym = 0.0
 690            for i, pr in enumerate(pi):
 691                if pr < self._EPS:
 692                    continue
 693                p_sym += pr * T_x[i, :].sum()
 694
 695            if p_sym < self._EPS:
 696                continue
 697            h -= p_sym * np.log2(p_sym)
 698
 699        self.set_complexity_measure("H_1", h)
 700        return h
 701
 702    #-------------------------------------------------------#
 703
 704    def rho_mu(self) -> float :
 705        
 706        """The *anticipated information* [^James_2018], p.3.:
 707
 708        .. math::
 709
 710            \\rho_{\\mu}= H(1) - h_{\\mu}
 711
 712        Returns:
 713            
 714            float: :math:`\\rho_{\\mu}`
 715
 716        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
 717            <https://arxiv.org/abs/1105.2988>
 718        """
 719
 720        m = self.get_complexity_measure_if_exists("rho_mu")
 721        
 722        if m is not None:
 723            return m
 724
 725        rho = self.H_1() - self.h_mu()
 726        
 727        self.set_complexity_measure("rho_mu", rho)
 728        
 729        return rho
 730
 731    #-------------------------------------------------------#
 732
 733    def block_convergence( self )  :
 734
 735        """
 736        Run [block entropy convergence](am_fast.html#block_entropy_convergence). Estimates [$\\mathbf{E}$](am_hmm.html#HMM.E), [$\\mathbf{S}$](am_hmm.html#HMM.S), [$\\mathbf{T}$](am_hmm.html#HMM.T_inf), and block measures[^crutchfield_exact_2016]:
 737
 738        - $\\mathbf{E}(L) = H(L) - L \\cdot h_{\\mu}$, 
 739
 740        - $\\mathbf{T}(L) = \\sum_{l=1}^{L} l \\left[ h_{\\mu}(l) - h_{\\mu} \\right]$, 
 741
 742        - $\\mathbf{S}(L) = \\sum_{l=0}^{L} \\mathcal{H}(l)$, 
 743
 744        - $H(L) = H[X_{0:L}]$, 
 745
 746        - $h_{\\mu}(L) = H(L) - H(L-1)$, and 
 747
 748        - $\\mathcal{H}(L) = -\\sum_{w \\in \\mathcal{A}^L} Pr(w) \\sum_{\\sigma \\in \\mathcal{S}} Pr(\\sigma|w) \\log_2 Pr(\\sigma|w)$.
 749
 750        You can plot these curves, and the block entropy curves using, [`amachine.HMM.draw_block_measure_curves`](am_hmm.html#HMM.draw_block_measure_curves), and [`amachine.HMM.draw_block_entropy_curve`](am_hmm.html#HMM.draw_block_entropy_curve). 
 751
 752        <img src="../resources/curves.png" alt="block measures plots" style="width: 100%; margin-left: 0%;">
 753
 754        Returns:
 755        
 756            ComplexityMeasures: An object containing the estimated measures with the following attributes:
 757            
 758            - E (float): The excess entropy.
 759            - T_inf (float): The transient information ($\\mathbf{T}$).
 760            - S (float): The synchronization information.
 761            - E_L (numpy.ndarray): The block excess entropy ($\\mathbf{E}(L)$).
 762            - T_L (numpy.ndarray): The block transient information ($\\mathbf{T}(L)$).
 763            - S_L (numpy.ndarray): The block synchronization information ($\\mathbf{S}(L)$).
 764            - H_L (numpy.ndarray): The block entropy ($H(L)$).
 765            - h_mu_L (numpy.ndarray): The entropy rate estimates ($h_{\\mu}(L)$).
 766            - H_sync (numpy.ndarray): The state-block synchronization ($\\mathcal{H}(L)$).
 767            - converged (bool): True if the algorithm converged.
 768
 769        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 770            Decomposition of Intrinsic Computation*, 2016.
 771            <https://arxiv.org/abs/1309.3792>
 772        """
 773
 774        trs = [ [] for _ in range( len( self.states ) ) ]
 775        for tr in self.transitions :
 776            trs[ tr.origin_state_idx ].append( ( 
 777                tr.symbol_idx, 
 778                float( tr.prob ),
 779                tr.target_state_idx ) )
 780
 781        pi = self.get_stationary_distribution()
 782
 783        state_dist = [ float( pi[ i ] ) for i in range( len( self.states ) ) ]
 784        branches = [(1.0, list(state_dist))]
 785
 786        print( "\nComputing Block Entropy\n" )
 787
 788        C = am_fast.block_entropy_convergence(
 789            h_mu            = self.h_mu(),
 790            n_states        = len( self.states ),
 791            n_symbols       = len( self.alphabet ),
 792            convergence_tol = 1e-8,
 793            precision       = 15,
 794            eps             = 1e-25,
 795            branches        = branches,
 796            trans           = trs,
 797            max_branches    = 30_000_000
 798        )
 799
 800        print( "Done\n" )
 801
 802        self.set_complexity_measure( f"E",       C.E )
 803        self.set_complexity_measure( f"S",       C.S )
 804        self.set_complexity_measure( f"T_inf",   C.T )
 805        self.set_complexity_measure( f"E_L",     C.E_L.tolist() )
 806        self.set_complexity_measure( f"T_L",     C.T_L.tolist() )
 807        self.set_complexity_measure( f"S_L",     C.S_L.tolist() )
 808        self.set_complexity_measure( f"H_L",     C.H_L.tolist() )
 809        self.set_complexity_measure( f"h_mu_L",  C.h_mu_L.tolist() )
 810        self.set_complexity_measure( f"H_sync",  C.H_sync.tolist() )
 811
 812        return C
 813
 814    #-------------------------------------------------------#
 815
 816    def E( self ) -> float :
 817
 818        """The *excess entropy* [^crutchfield_exact_2016], p.4:
 819
 820        .. math::
 821
 822            \\mathbf{E} \\equiv \\sum_{L=1}^{\\infty} I[X_{-\\infty:0}; X_{0:\\infty}]
 823        
 824        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 825
 826        .. note::
 827
 828            **Interpretations**
 829
 830            * The information from the past that reduces uncertainty in the future [^crutchfield_exact_2016].
 831            * How much information an observer must extract to synchronize to the process.
 832            * Measures how long the process appears more complex than it asymptotically is.
 833            * Vanishes for immediately synchronizable processes.
 834
 835        Returns:
 836        
 837            float: :math:`\\mathbf{E}`
 838
 839        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 840            Decomposition of Intrinsic Computation*, 2016.
 841            <https://arxiv.org/abs/1309.3792>
 842        """
 843
 844        m = self.get_complexity_measure_if_exists( "E" )
 845
 846        if m is not None :
 847            return m
 848
 849        try : 
 850            msp = self.get_msp()
 851            E, S, T = msp.get_E_S_T()
 852            self.set_complexity_measure( "E", E )
 853            self.set_complexity_measure( "S", S )
 854            self.set_complexity_measure( "T_inf", T )
 855            
 856        except Exception as e :
 857
 858            print( f"MSP failed {e}" )
 859
 860            C = self.block_convergence()	
 861            E = C.E
 862            self.set_complexity_measure( "E", E )
 863
 864        return E
 865
 866    #-------------------------------------------------------#
 867
 868    def S( self ) -> float :
 869
 870        """The *synchronization* information:
 871
 872        .. math::
 873
 874            \\mathbf{S} \\equiv \\sum_{L=1}^{\\infty} \\mathcal{H}(L),
 875
 876        where :math:`\\mathcal{H}(L)` is the average state uncertainty having seen all length-L words [^crutchfield_exact_2016], p.4.
 877
 878        .. note::
 879
 880            **Interpretations**
 881
 882            * The total amount of state information that an observer must extract to become synchronized [^crutchfield_exact_2016].
 883
 884        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 885
 886        Returns:
 887        
 888            float: :math:`\\mathbf{S}`
 889
 890        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 891            Decomposition of Intrinsic Computation*, 2016.
 892            <https://arxiv.org/abs/1309.3792>
 893        """
 894
 895        m = self.get_complexity_measure_if_exists( "S" )
 896
 897        if m is not None :
 898            return m
 899
 900        try : 
 901            msp = self.get_msp()
 902            E, S, T = msp.get_E_S_T()
 903            self.set_complexity_measure( "E", E )
 904            self.set_complexity_measure( "S", S )
 905            self.set_complexity_measure( "T_inf", T )
 906
 907        except Exception as e :
 908            print( f"{e} \nFalling back to iterative estimation.")
 909            C = self.block_convergence()	
 910            S = C.S
 911            self.set_complexity_measure( "S", S )
 912
 913        return S
 914
 915    #-------------------------------------------------------#
 916
 917    def T_inf( self ) -> float :
 918
 919        """The *transient information*[^crutchfield_exact_2016], p.4:
 920
 921        .. math::
 922
 923            \\mathbf{T} \\equiv \\sum_{L=1}^{\\infty} L \\left[ h_{\\mu}(L) - h_{\\mu} \\right]
 924
 925        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 926
 927        .. note::
 928
 929            **Interpretations**
 930
 931            * The amount of information one must extract from observations so that the block entropy converges to its linear asymptote[^crutchfield_exact_2016].
 932
 933        Returns:
 934        
 935            float: :math:`\\mathbf{T}`
 936
 937        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 938            Decomposition of Intrinsic Computation*, 2016.
 939            <https://arxiv.org/abs/1309.3792>
 940        """
 941
 942        m = self.get_complexity_measure_if_exists( "T_inf" )
 943
 944        if m is not None :
 945            return m
 946
 947        try : 
 948            msp = self.get_msp()
 949            E, S, T = msp.get_E_S_T()
 950            self.set_complexity_measure( "E", E )
 951            self.set_complexity_measure( "S", S )
 952            self.set_complexity_measure( "T_inf", T )
 953            T_inf = T
 954
 955        except Exception as e :
 956            print( f"{e} \nFalling back to iterative estimation.")
 957            C = self.block_convergence()	
 958            T_inf = C.T
 959
 960        return T_inf
 961
 962    #-------------------------------------------------------#
 963
 964    def chi( self ) -> float :
 965
 966        """The foward crypticity[^crutchfield_crypticity_2009][^Mahoney_crypticity_2021], p.2:
 967
 968        .. math::
 969
 970            \\chi = C_{\\mu} - \\mathbf{E}
 971
 972        :math:`C_{\\mu}` is trivially computed from the stationary distribution in :meth:`C_mu` and :math:`\\mathbf{E}` in :meth:`E`.
 973
 974        .. note::
 975
 976            **Interpretations**
 977
 978            * Difference between internal stored information and apparent information to an observer.
 979            * How muching information is hiding in the system.
 980
 981        Returns:
 982        
 983            float: :math:`\\chi`
 984
 985        [^crutchfield_crypticity_2009]: Crutchfield et al., Time’s barbed arrow: Irreversibility, crypticity, and stored information, 2009.
 986            <https://arxiv.org/abs/0902.1209>
 987
 988        [^Mahoney_crypticity_2021]: Mahoney et al., Information Accessibility and Cryptic Processes, 2021.
 989            <https://arxiv.org/abs/0905.4787>
 990        """
 991
 992        m = self.get_complexity_measure_if_exists( "chi" )
 993
 994        if m is not None :
 995            return m
 996
 997        chi = self.C_mu() - self.E()
 998
 999        if chi < 0 :
1000            
1001            # if chi is 0, accumulated floating point error can result in small negative values
1002            if chi < -1e-5:
1003                warnings.warn(f"Crypticity is negative ({chi:.6e}).")
1004            
1005            chi = np.clip( chi, 0 )
1006
1007        self.set_complexity_measure( "chi", chi )
1008
1009        return chi
1010
1011    #-------------------------------------------------------#
1012    #                      Properties                       #
1013    #-------------------------------------------------------#
1014
1015    def is_row_stochastic(self) :
1016
1017        """
1018        Check that all states have outgoing transition probabilities that sum to 1.
1019        """
1020
1021        sums = np.zeros( len( self.states ) )
1022        for tr in self.transitions :
1023            sums[ tr.origin_state_idx ] += tr.prob
1024
1025        return np.allclose( sums, 1.0 )
1026
1027    #-------------------------------------------------------#
1028
1029    def is_unifilar(self):
1030        """
1031        Check that no state emits the same symbol on transitions to different states.
1032        """
1033
1034        seen_state_symbols = set()
1035        for tr in self.transitions:
1036
1037            sym_key = (tr.origin_state_idx, tr.symbol_idx)
1038
1039            # If we've seen this origin+symbol or origin+target before
1040            if sym_key in seen_state_symbols :
1041                return False
1042
1043            seen_state_symbols.add(sym_key)
1044
1045        return True
1046
1047    #-------------------------------------------------------#
1048
1049    def is_strongly_connected(self) :
1050
1051        """
1052        Check if every state is reachable from every other state. Relies on [nx.is_strongly_connected](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.components.is_strongly_connected.html).
1053        """
1054
1055        return nx.is_strongly_connected( self.as_digraph() )
1056
1057    #-------------------------------------------------------#
1058
1059    def is_aperiodic(self) :
1060
1061        """
1062        Checks if machine is periodic. Relies on [nx.is_aperiodic](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.dag.is_aperiodic.html), "A strongly connected directed graph is aperiodic if there is no integer k > 1 that divides the length of every cycle in the graph."
1063        """
1064
1065        return nx.is_aperiodic( self.as_digraph() )
1066
1067    #-------------------------------------------------------#
1068
1069    def _is_minimal_as_dfa( self, topological_only : bool, verbose=True ) :
1070
1071        with_probs = not topological_only
1072
1073        # Construct the DFA
1074        dfa = self.as_dfa( with_probs=with_probs )
1075
1076        # Minimize the DFA
1077        #dfa = dfa.minify(retain_names=True)
1078        dfa = am_fast.minify_cpp( dfa, retain_names=True )
1079
1080        # check we have minimal number of states
1081        if len( dfa.states ) != len( self.states ) :
1082            if verbose : 
1083                print( f"Not minimal reduces from {len( self.states )} to {len( dfa.states )} states" )
1084            return False
1085
1086        return True
1087
1088    def is_topological_epsilon_machine( self, verbose=True ) :
1089
1090        """
1091        Checks if the HMM is a topological $\\epsilon$-machine [^1].
1092
1093        [^1]: Johnson et al, Enumerating Finitary Processes, 2024.
1094            <https://arxiv.org/abs/1011.0036>
1095        """
1096
1097        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1098            if verbose : 
1099                print( f"Either non unifilar or not strongly connected" )
1100            return False
1101        else :
1102            return self._is_minimal_as_dfa( topological_only=True, verbose=verbose )
1103
1104    def is_epsilon_machine( self, verbose=True ) :
1105
1106        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1107            if verbose : 
1108                print( f"Either non unifilar or not strongly connected" )
1109            return False
1110        else :
1111            return self._is_minimal_as_dfa( topological_only=False, verbose=verbose )
1112
1113    #-------------------------------------------------------#
1114    #                 Structural Operations                 #
1115    #-------------------------------------------------------#
1116
1117    def minimize(self, retain_names: bool = True, verbose=False):
1118
1119        """
1120        Minimizes the HMM, resulting in an :math:`\\epsilon-`machine if the HMM
1121        is unifilar and strongly connected. Converts the HMM to a DFA with symbols
1122        labeled jointly with symbols and probabilities, and uses Myhill-Nerode 
1123        equivalence for minimization. Relies on `automata_lib` and uses
1124         `automata.fa.dfa.DFA.minify` with `allow_partial=True`, and all states
1125         final.
1126
1127        Args:
1128            retain_names (bool): If `True`, the merged states will be named by their union, e.g. `{s_0, s_1}`, and other states will retain their origion names. Otherwise, they will be relabled `{ '0', '1', ..., 'n-1' }`.
1129
1130        Returns:
1131        
1132            automata.fa.dfa.DFA : the resulting DFA.
1133        """
1134
1135        if self._already_minimized :
1136            return
1137
1138        start = time.perf_counter()
1139
1140        if not self.is_unifilar():
1141            raise ValueError(
1142                "DFA minimization is not valid for non-unifilar HMMs"
1143            )
1144
1145        was_strongly_connected = self.is_strongly_connected()
1146        was_row_stochastic     = self.is_row_stochastic()
1147        was_unifilar           = self.is_unifilar()
1148
1149        n_states_before = len(self.states)
1150
1151        dfa = self.as_dfa(with_probs=True)
1152
1153        min_dfa = self.as_dfa(with_probs=True).minify(retain_names=True)
1154        #min_dfa = am_fast.minify_cpp( dfa, retain_names=True )
1155
1156        # Build lookup from original state index -> CausalState object
1157        orig_state   = {i: s for i, s in enumerate(self.states)}
1158        eq_list      = list(min_dfa.states)
1159
1160        start_eq = min_dfa.initial_state
1161        
1162        # Separate the start state, then sort the rest by the 
1163        # smallest original state index inside each equivalence class.
1164        other_eqs = [eq for eq in eq_list if eq != start_eq]
1165        other_eqs.sort(key=lambda eq: min(eq))
1166
1167        # Recombine so start eq comes first, followed by the sorted remaining classes
1168        eq_list = [start_eq] + other_eqs
1169        # ----------------------------------------------------------
1170
1171        # Recompute eq_to_idx with the new ordering
1172        eq_to_idx = {eq: i for i, eq in enumerate(eq_list)}
1173
1174        # new_start is now guaranteed to be 0
1175        new_start = 0
1176
1177        # Map each original state index -> its equivalence class
1178        # Guard: minify() silently drops unreachable states
1179        orig_to_eq = {s: eq for eq in min_dfa.states for s in eq}
1180
1181        # Build lookup from original state index -> its transitions
1182        orig_trs = defaultdict(list)
1183        for t in self.transitions:
1184            orig_trs[t.origin_state_idx].append(t)
1185
1186        new_trs = []
1187        for eq in min_dfa.states:
1188            rep        = next(iter(eq))
1189            origin_idx = eq_to_idx[eq]
1190            for t in orig_trs[rep]:
1191
1192                target_eq  = orig_to_eq[t.target_state_idx]
1193                target_idx = eq_to_idx[target_eq]
1194                
1195                new_trs.append(
1196                    t.modified_deep_copy(
1197                        origin_state_idx = origin_idx,
1198                        target_state_idx = target_idx
1199                    )
1200                )
1201
1202        members_list = [[orig_state[i] for i in sorted(eq)] for eq in eq_list]  # sorted for determinism
1203
1204        # Compute new names
1205        if retain_names:
1206            new_names = [
1207                "{" + ",".join(str(m.name) for m in members) + "}" if len(members) > 1
1208                else members[0].name
1209                for members in members_list
1210            ]
1211        else:
1212            new_names = [str(j) for j in range(len(eq_list))]
1213
1214        old_name_to_new_name = {
1215            m.name: new_names[j]
1216            for j, members in enumerate(members_list)
1217            for m in members
1218        }
1219
1220        # Build the new states, preserving classes and isomorphs regardless of naming
1221        new_states = []
1222        for j, (eq, members, name) in enumerate(zip(eq_list, members_list, new_names)):
1223            
1224            classes : defaultdict[int,set[str]]= {}
1225            for m in members:
1226                classes |= m.classes
1227            
1228            isomorphs = {
1229                old_name_to_new_name.get(iso, iso)
1230                for m in members
1231                for iso in m.isomorphs
1232                if old_name_to_new_name.get(iso, iso) != name
1233            }
1234            
1235            pseudo_isomorphs = {
1236                old_name_to_new_name.get(iso, iso)
1237                for m in members
1238                for iso in m.pseudo_isomorphs
1239                if old_name_to_new_name.get(iso, iso) != name
1240            }
1241
1242            new_states.append(CausalState(
1243                name      = name,
1244                classes   = classes,
1245                isomorphs = isomorphs,
1246                pseudo_isomorphs = pseudo_isomorphs
1247            ))
1248
1249        self.set_states(new_states)
1250        self.set_transitions(new_trs)
1251        self.start_state = new_start
1252
1253        if n_states_before == len(new_states) and verbose :
1254            print( f"{n_states_before} state HMM was already minimal.\n" )
1255        elif verbose :
1256            print( f"Minimized from {n_states_before} to {len(new_states)}\n" )
1257
1258        if not ( was_strongly_connected ==  self.is_strongly_connected() ) :
1259            raise RuntimeError(
1260                f"Minimization broke strongly connected"
1261            )
1262
1263        if not ( was_row_stochastic ==  self.is_row_stochastic() ) :
1264            raise RuntimeError(
1265                f"Minimization broke row stochasticity"
1266            )
1267
1268        if not( was_unifilar == self.is_unifilar() ) :
1269            raise RuntimeError(
1270                f"Minimization broke unifilarity"
1271            )
1272
1273        self._already_minimized = True
1274
1275    #-------------------------------------------------------#
1276    #                      Modifiers                        #
1277    #-------------------------------------------------------#
1278
1279
1280    def collapse_to_largest_strongly_connected_subgraph( self, rename_states=True ) :
1281
1282        was_q_weighted = self._has_valid_rational_probabilities
1283
1284        # get equivalent networkx graph
1285        G = self.as_digraph()
1286
1287        # if already strongly connected, nothing to do
1288        if not nx.is_strongly_connected( G ) :
1289
1290            start = time.perf_counter()
1291            subgraph_nodes = list( nx.strongly_connected_components( G ) )
1292
1293            # decompose into strongly connected components and sort by length
1294            # subgraph_nodes = list(nx.strongly_connected_components( G ))
1295            subgraph_nodes.sort(key=len)
1296            component_state_set = subgraph_nodes[-1]
1297
1298            # Take the largest strongly connected component (as list of state names)
1299            component_states = sorted( list( component_state_set ) )
1300
1301            # make temporary copies of the old transitions and states
1302            old_transitions = [
1303                tr.deepcopy()
1304                for tr in self.transitions
1305            ]
1306
1307            old_states = [
1308                s.deepcopy()
1309                for s in self.states
1310            ]
1311
1312            self.set_states(
1313                states=[ 
1314                    state
1315                    for i, state in enumerate( old_states ) if i in component_state_set
1316                ]
1317            )
1318
1319            # we will build new transition list based on those belonging to the component
1320            self.set_transitions( transitions= [] )
1321
1322            # for tracking which new transitions leave each state
1323            transitions_from_state = { state : set() for state in component_states }
1324            new_transitions = []
1325
1326            for tr in old_transitions :
1327
1328                origin_state_name = old_states[ tr.origin_state_idx ].name
1329                target_state_name = old_states[ tr.target_state_idx ].name
1330
1331                # skip transitions that connect separate strongly connected components
1332                if not ( tr.origin_state_idx in component_state_set and tr.target_state_idx in component_state_set ) :
1333                    continue
1334
1335                # track transitions (by index in new transitions list) that leave this state
1336                transitions_from_state[ tr.origin_state_idx ].add( len( new_transitions ) )
1337
1338                my_origin_state_idx = self.state_idx_map[ origin_state_name ]
1339                my_target_state_idx = self.state_idx_map[ target_state_name ]
1340
1341                new_transitions.append( 
1342                    tr.modified_deep_copy(  
1343                        origin_state_idx=my_origin_state_idx,
1344                        target_state_idx=my_target_state_idx
1345                    )
1346                )
1347
1348            self.set_transitions( transitions=new_transitions )
1349
1350            # if we removed an outgoing transition from a state, we need to distribute its probability 
1351            # among the remaining outgoing transitions from the state
1352            
1353            transition_list = list( self.transitions )
1354            
1355            for state in component_states :
1356                
1357                # get the set of transitions leaving this state
1358                state_trs = transitions_from_state[ state ]
1359
1360                # sum the probabilities of the outgoing transitions from the state
1361                p_sum = np.sum( [ self.transitions[ i ].prob for i in state_trs ] )
1362
1363                # how much probability is missing
1364                diff = 1.0 - p_sum
1365
1366                # if significant difference
1367                if abs( diff ) > self._EPS : 
1368
1369                    # calculate how much of the difference each transition gets
1370                    adjustment = diff / len( state_trs )
1371                    
1372                    new_transitions = []
1373
1374                    # update the transitions
1375                    for i in state_trs :
1376
1377                        # adjusted probability
1378                        transition_list[ i ] = self.transitions[ i ].modified_deep_copy(
1379                            prob=self.transitions[ i ].prob + adjustment,
1380                            pq=None
1381                        )
1382
1383            self.set_transitions( transition_list )
1384
1385            if rename_states :
1386                self.set_states( [
1387                    s.modified_deep_copy( name=f"{i}" )
1388                    for s in self.states
1389                ] )
1390
1391        if was_q_weighted :
1392            self.to_q_weighted()
1393
1394    def to_q_weighted( self, denominator_limit=1000 ) :
1395
1396        """
1397        Approximates the existing transition probabilities with exact fractions, stores 
1398        the fractional probabilities as Fraction in Transition.pq, and sets the floating
1399        point probabilty to `float(pq)`. If `denominator_limit` is too small for a sane 
1400        conversion, the function recurses with `denominator_limit=denominator_limit*10`.
1401
1402        Args:
1403            denominator_limit (int): The initial input to :meth:`Fraction.limit_denominator` in 
1404            the conversion.
1405        """
1406
1407        if self._has_valid_rational_probabilities :
1408            return
1409
1410        if not self.is_row_stochastic() :
1411            raise ValueError( "Cannot convert to q-weighted because not row stochastic" )
1412
1413        t_from = [[] for _ in range(len(self.states))]
1414
1415        for i, tr in enumerate( self.transitions ) :
1416            t_from[ tr.origin_state_idx ].append( i )
1417
1418        new_transitions = []
1419        for t_list in t_from :
1420            
1421            if not t_list : 
1422                continue
1423
1424            p_q_sum = Fraction(0,1)
1425            p_qs = []
1426
1427            for t_idx in t_list :
1428            
1429                p_q = Fraction( self.transitions[ t_idx ].prob ).limit_denominator( denominator_limit )
1430                p_q_sum += p_q
1431                p_qs.append( p_q )
1432
1433            if p_q_sum != Fraction(1,1) :
1434                
1435                max_pq_i = np.argmax( p_qs )
1436                max_oq = p_qs[ max_pq_i ]
1437
1438                diff = p_q_sum - Fraction(1,1)
1439
1440                # If recurse with higher resolution
1441                if diff > max_oq :
1442                    return self.to_q_weighted( denominator_limit*10 )
1443                else :
1444                    p_qs[ max_pq_i ] -= diff
1445
1446            for i, t_idx in enumerate( t_list ) :	
1447                new_transitions.append( 
1448                    self.transitions[  t_idx ].modified_deep_copy(
1449                        prob=float(p_qs[ i ]),
1450                        pq=p_qs[ i ]
1451                    )
1452                )
1453
1454        self.set_transitions( new_transitions )
1455        self._has_valid_rational_probabilities = True
1456
1457    #-------------------------------------------------------#
1458    #                    Data Generation                    #
1459    #-------------------------------------------------------#
1460
1461    def isomorphic_shift(
1462        self,
1463        input_symbol_indices: np.ndarray,
1464        input_state_indices:  np.ndarray,
1465        shift : int = 1
1466    ) -> dict[str, np.ndarray]:
1467
1468        """
1469        Generates a new sequence of symbols that are permuted with the symbols emitted by
1470        isomorphic states, if they exists.
1471
1472        :math:`\\sigma_o = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i]\\right]`<br>
1473        :math:`\\sigma_t = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i+1]\\right]`
1474 
1475        :math:`\\mathcal{I}(\\sigma_o) = \\\\{\\sigma^0_o,\\, \\sigma^1_o,\\, \\dots,\\, \\sigma^{n-1}_o \\\\}`<br>
1476        :math:`\\mathcal{I}(\\sigma_t) = \\\\{\\sigma^0_t,\\, \\sigma^1_t,\\, \\dots,\\, \\sigma^{n-1}_t \\\\}`
1477 
1478        :math:`k = \\bigl(\\mathcal{I}(\\sigma_o).\\texttt{index}(\\sigma_o) + \\texttt{shift}\\bigr) \\bmod n`
1479 
1480        :math:`\\texttt{output\\_symbol\\_indices}[i]   := T(\\sigma_o^k,\\, \\sigma_t^k).\\text{symbol\\_index}`<br>
1481        :math:`\\texttt{output\\_state\\_indices}[i]    := \\mathcal{S}.\\texttt{index}(\\sigma_o^k)`<br>
1482        :math:`\\texttt{output\\_state\\_indices}[i+1]  := \\mathcal{S}.\\texttt{index}(\\sigma_t^k)`
1483
1484        Where :math:`\\mathcal{I}(\\sigma)`` is the ordered set of states isomorphic to :math:`\\sigma` including :math:`\\sigma` itself.
1485
1486        Args:
1487            input_symbol_indices (np.ndarray): The sequence of generated symbols.
1488            input_state_indices (np.ndarray): The sequence of states that generated symbols with the final state at the end.
1489            shift : int: How much to shift the symbols across the isomorphic states.
1490        """
1491        
1492        if not any( state.isomorphs for state in self.states ):
1493            raise ValueError("HMM has no states with isomorphs")
1494
1495        inputs = np.asarray(input_symbol_indices)
1496        states = np.asarray(input_state_indices)
1497
1498        n_states = len(self.states)
1499
1500        tr_sym_table    = np.full((n_states, n_states), -1, dtype=np.int32)
1501        is_pseudo_table = np.zeros((n_states, n_states), dtype=bool)
1502        tr_cross_table  = np.zeros((n_states, n_states), dtype=bool)
1503
1504        for tr in self.transitions:
1505            tr_sym_table[ tr.origin_state_idx, tr.target_state_idx ] = tr.symbol_idx
1506            tr_cross_table[tr.origin_state_idx, tr.target_state_idx] = tr.composition_depth > 0
1507
1508        for i, state in enumerate(self.states):
1509            for p_iso in state.pseudo_isomorphs:
1510                j = self.state_idx_map[p_iso]
1511                is_pseudo_table[i, j] = True
1512
1513        # Build isomorph remapping: identity by default, overridden where isomorphs exist
1514        iso_table = np.arange( n_states, dtype=np.int32 )
1515
1516        for i, state in enumerate(self.states):
1517            effective_isos = state.isomorphs | state.pseudo_isomorphs
1518            if len(effective_isos) > 0:
1519                isormorphs_with_identity = sorted([i] + [self.state_idx_map[iso] for iso in effective_isos])
1520                pos = isormorphs_with_identity.index(i)
1521                iso_table[i] = isormorphs_with_identity[(pos + shift) % len(isormorphs_with_identity)]
1522
1523        origins = states[:-1]
1524        targets = states[1:]
1525
1526        out_origins = iso_table[origins]
1527        out_targets = iso_table[targets]
1528
1529        rotated_symbols = tr_sym_table[ out_origins, out_targets ]
1530
1531        ###########################################################################
1532        # handle attempts to rotate symbols emitted by pseudo-isomorphic states
1533        
1534        # are the isomorphs used for the origin and target shift pseudo-isomorphism?
1535        origin_is_pseudo = is_pseudo_table[origins, out_origins]
1536        target_is_pseudo = is_pseudo_table[targets, out_targets]
1537
1538        # does the edge cross a component
1539        is_cross = tr_cross_table[ origins, targets ]
1540
1541        # is_cross is sufficient condition for single-level compositions, 
1542        # but condition the combined is required for multi-level composition
1543        invalid_shift = is_cross & origin_is_pseudo & target_is_pseudo
1544
1545        final_symbols = np.where( invalid_shift, inputs, rotated_symbols)
1546
1547        if np.any(final_symbols == -1):
1548            raise RuntimeError(
1549                "Invalid isomorphic shift: topology invariant violated."
1550            )
1551
1552        ############################################################################
1553
1554        invalid_state_shift = np.append( invalid_shift, invalid_shift[-1] )
1555
1556        rotated_states = np.empty(states.size, dtype=states.dtype)
1557        rotated_states[:-1] = out_origins
1558        rotated_states[-1]  = out_targets[-1]
1559
1560        rotated_states = np.where( invalid_state_shift, states, rotated_states )
1561
1562        return {
1563            "symbol_index": final_symbols.astype(inputs.dtype),
1564            "state_index":  rotated_states,
1565        }
1566
1567    def generate_belief_trajectory_from( 
1568        self, 
1569        symbols : np.ndarray ) :
1570
1571        """
1572        Tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1573
1574        Returns:
1575            np.ndarray: The belief state sequence. 
1576        """
1577
1578        T_x = self.get_T_X()
1579        pi  = self.get_stationary_distribution()
1580
1581        mu        = pi.copy()
1582        states    = np.zeros(( len(symbols) + 1, len(mu)))
1583        states[0] = mu
1584        
1585        for i, x in enumerate( symbols ) :
1586            mu = mu @ T_x[x]
1587            mu = mu / mu.sum()
1588            states[i+1] = mu
1589        return states
1590
1591    def generate_belief_trajectory( self, 
1592        n_steps : int, 
1593        random_seed : int=42 ) -> np.ndarray:
1594        """
1595        Generates `n_steps` symbols from the HMM, then tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1596
1597        Returns:
1598            np.ndarray: The belief state sequence. 
1599        """
1600
1601        trs = self.get_transition_list()
1602
1603        data = am_fast.generate_data(
1604            n_gen=n_steps,
1605            start_state=self.start_state,
1606            transitions=trs,
1607            alphabet=sorted(list(self.alphabet)),
1608            include_states=False,
1609            random_seed=random_seed
1610        )
1611
1612        return self.generate_belief_trajectory_from( data["symbol_index"] )
1613        
1614    def generate_data(
1615        self,
1616        file_prefix: str,
1617        n_gen: int,
1618        include_states: bool,
1619        row_size : int | None = None,
1620        include_belief_states : bool=False,
1621        isomorphic_shifts : set[int] | None = None,
1622        with_component_map : bool = True, 
1623        random_seed : int=42 ) -> dict[str,Any] : 
1624
1625        if isomorphic_shifts is not None and not include_states :
1626            raise ValueError( "Isomorphic inversion requires include_states=True" )
1627
1628        trs = self.get_transition_list()
1629
1630        data = am_fast.generate_data(
1631            n_gen=n_gen,
1632            start_state=self.start_state,
1633            transitions=trs,
1634            alphabet=sorted(list(self.alphabet)),
1635            include_states=include_states,
1636            random_seed=random_seed
1637        )
1638
1639        if isomorphic_shifts is not None :
1640
1641            data[ "isomorphic_shifts" ] = {}
1642
1643            for shift in isomorphic_shifts :
1644
1645                try : 
1646
1647                    shifted = self.isomorphic_shift(
1648                        input_symbol_indices=data[ "symbol_index" ], 
1649                        input_state_indices=data[ "state_index" ], 
1650                        shift=shift
1651                    )
1652
1653                    data[ "isomorphic_shifts" ][ f"{shift}" ] = {
1654                        "symbol_index" : shifted[ "symbol_index" ],
1655                        "state_index"  : shifted[ "state_index" ]
1656                    }
1657
1658                except Exception as e :
1659                    print( f"Exception {e}" )
1660
1661        if include_belief_states :
1662            belief_states = self.generate_belief_trajectory_from( data[ "symbol_index" ] )
1663            data[ "belief_states" ] = belief_states
1664
1665        metadata = self.get_metadata()
1666
1667        # Maps global state index to the component instance id the state belongs to at 
1668        # a given depth in the composition. 
1669        # somewhat hackish, namely the string based encoding, parsing, and fact that 
1670        # class cmi is only added in compositionally constructed machines
1671        if with_component_map :
1672            
1673            cmp_map = defaultdict(dict)
1674            all_cmis : set[tuple[str,str]] = set()
1675            
1676            for state_index, state in enumerate( self.states ) : 
1677                for level, l_classes in state.classes.items() :
1678                    
1679                    cmi = None
1680
1681                    level_str = str(level)
1682                    state_idx_str = str(state_index)
1683
1684                    for cls in l_classes : 
1685                        cls_tpy = cls.split( "_" )[ 0 ] 
1686                        if cls_tpy == "cmi" :
1687                            cmi = cls
1688                            break
1689                    
1690                    if cmi is not None : 
1691                        cmp_map[ state_idx_str ][ level_str ] = cmi
1692
1693                    if state_idx_str in cmp_map :
1694                        all_cmis.add( ( level_str, cmp_map[ state_idx_str ][ level_str ] ) )
1695            
1696            cmi_indices : dict[ str, dict[str, int ] ] = defaultdict(dict)
1697            for idx, cmi in enumerate( sorted( all_cmis ) ) :
1698                cmi_indices[ cmi[0] ][ cmi[1] ] = idx
1699
1700            metadata["state_component_map" ] = cmp_map
1701            metadata["all_components"      ] = sorted(all_cmis)
1702            metadata["component_indices"   ] = cmi_indices
1703
1704        if "state_index" in data :
1705            index_histogram(
1706                data=data["state_index"], 
1707                output_path=file_prefix + "_state_frequency",
1708                title="State Frequency",
1709                x_label="state index",
1710                show=False
1711            )
1712
1713        am_fast.save_data(
1714            data=data,
1715            file_prefix=file_prefix,
1716            alphabet=sorted(list(self.alphabet)),
1717            n_states=len( self.states ),
1718            start_state=self.start_state,
1719            random_seed=random_seed,
1720            row_size=row_size,
1721            machine_metadata=metadata )
1722
1723        return data
1724
1725    #-------------------------------------------------------#
1726    #                   Digraphs and DFAs                   #
1727    #-------------------------------------------------------#
1728
1729    def as_digraph( self ) -> nx.DiGraph :
1730
1731        """
1732        Builds a [networkx.DiGraph](https://networkx.org/documentation/stable/reference/classes/digraph.html) constructed from the machine's transitions with no edge symbols or weights.
1733
1734        Returns:
1735        
1736            networkx.DiGraph : the resulting graph.
1737        """
1738
1739        G = nx.DiGraph()
1740        G.add_nodes_from( [ i for i, s in enumerate( self.states ) ] )
1741
1742        for tr in self.transitions :
1743            G.add_edge( tr.origin_state_idx, tr.target_state_idx )
1744
1745        return G
1746
1747    def as_dfa( self, with_probs : bool ) :
1748
1749        """
1750        Builds an [automata.fa.dfa.DFA](https://caleb531.github.io/automata/api/fa/class-dfa/) constructed from the machine's transitions.
1751
1752        Args:
1753            with_probs (bool): If true the DFA transitions are labeled based on
1754                the symbol of the machines transition concatenated with its
1755                probability, othwise, the only the symbols.
1756
1757        Returns:
1758        
1759            automata.fa.dfa.DFA : the resulting DFA.
1760        """
1761
1762        precision=8
1763
1764        def edge_label( symb, prob ) :
1765            return f"({symb},{round(prob, precision)})"
1766
1767        # Build states, symbols, and transitions 
1768        dfa_states  = { i for i, _ in enumerate( self.states ) }
1769            
1770        if not with_probs :
1771            dfa_symbols = set( { str(t.symbol_idx) for t in self.transitions } )
1772        else : 
1773            dfa_symbols = set( { edge_label( t.symbol_idx, t.prob ) for t in self.transitions } )
1774
1775        dfa_transitions = defaultdict(dict)
1776
1777        if not with_probs :
1778            for t in self.transitions :
1779                dfa_transitions[ t.origin_state_idx ][ t.symbol_idx ] = t.target_state_idx
1780        else :
1781            for t in self.transitions :
1782                dfa_transitions[ t.origin_state_idx ][ edge_label( t.symbol_idx, t.prob ) ] = t.target_state_idx
1783
1784        # Construct the DFA
1785        return DFA(
1786            states=dfa_states,
1787            input_symbols=dfa_symbols,
1788            transitions=dfa_transitions,
1789            initial_state=self.start_state,
1790            allow_partial=True,
1791            final_states={ 
1792                s for s in dfa_states
1793            }
1794        )
class HMM:
  32class HMM :
  33
  34    """Hidden Markov model implementing epsilon machines, mixed state presentations,
  35    complexity measures, and data generation.
  36
  37    Args:
  38        states (list[CausalState] | None): A list of causal states.
  39        transitions (list[Transition] | None): A list of transitions between states.
  40        start_state (int): Index of the start state.
  41        alphabet (list[str]): List of symbols making up the alphabet.
  42        name (str): Name of the model.
  43        description (str): Description of the model.
  44    """
  45
  46    _id_generator = itertools.count(1)
  47
  48    def __init__( 
  49        self,
  50        states      : Sequence[CausalState],
  51        transitions : Sequence[Transition],
  52        alphabet    : Sequence[str],
  53        start_state : int = 0,
  54        composition_depth : int = 0,
  55        name        : str = "",
  56        description : str = "" ) : 
  57
  58        self.unique_id = next(HMM._id_generator)
  59
  60        # MappingProxyType offers immutable view of dict
  61        self.symbol_idx_map : MappingProxyType = MappingProxyType( {} )
  62        self.state_idx_map  : MappingProxyType = MappingProxyType( {} )
  63
  64        self.alphabet    = tuple( alphabet    )   
  65        self.states      = tuple( states      )
  66        self.transitions = tuple( transitions )
  67
  68        self.update_symbol_idx_map()
  69        self.update_state_idx_map()
  70
  71        self.name : str = name
  72        self.description : str = description
  73
  74        self.composition_depth = composition_depth
  75
  76        self.start_state : int = start_state
  77
  78        # --- derived --------
  79
  80        self._complexity : dict[str, Any] = {}
  81        self._pi_fractional = None
  82        self._pi : np.ndarray | None = None
  83        
  84        self._T : np.ndarray | None = None
  85        self._T_x  : list[np.ndarray] | None = None
  86        self._msp : MSP | None = None
  87
  88        self._reverse_am : HMM | None = None
  89        self._has_valid_rational_probabilities : bool = False
  90        self._already_minimized : bool = False
  91
  92        # --- const ----------
  93
  94        self._EPS : float = 1e-12
  95
  96    #-------------------------------------------------------#
  97    #             Setters and State Management              #
  98    #-------------------------------------------------------#
  99    
 100    def clear_cache(self) :
 101
 102        """
 103        Reset all derived properties so they will be recomputed when requested later.
 104        """
 105
 106        self._complexity = {}
 107        self._T = None
 108        self._T_x = None
 109        self._pi = None
 110        self._pi_fractional = None
 111        self._msp = None 
 112        self._reverse_am = None
 113
 114        self._has_valid_rational_probabilities = False
 115        self._already_minimized = False
 116
 117    def update_symbol_idx_map(self) :
 118        new_idx_map = {}
 119        for idx, symbol in enumerate( self.alphabet ) :
 120            new_idx_map[ symbol ] = idx
 121        self.symbol_idx_map = MappingProxyType( new_idx_map )
 122
 123    def update_state_idx_map(self) :
 124        new_idx_map = {}
 125        for idx, state in enumerate( self.states ) :
 126            new_idx_map[ state.name ] = idx
 127        self.state_idx_map = MappingProxyType( new_idx_map )
 128
 129    def set_states( self, states : Sequence[CausalState] ) :
 130        self.clear_cache()        
 131        self.states = tuple( states )
 132        self.update_state_idx_map()
 133
 134    def set_alphabet( self, alphabet : Sequence[str] ) :
 135
 136        self.clear_cache()
 137
 138        old_alphabet = tuple( self.alphabet )
 139
 140        self.alphabet = tuple( sorted( set( alphabet ) ) )
 141        self.update_symbol_idx_map()
 142
 143        new_transitions = []
 144        for i, tr in enumerate( self.transitions ) :
 145            symbol = old_alphabet[ tr.symbol_idx ]
 146            new_transitions.append(  
 147                tr.modified_deep_copy(
 148                    symbol_idx=self.symbol_idx_map[ symbol ]
 149                )
 150            )
 151
 152        self.set_transitions( new_transitions )
 153
 154    def set_transitions( self, transitions : Sequence[Transition] ) :
 155        self.clear_cache()
 156        self.transitions = tuple( transitions )
 157
 158    def get_complexity_measure_if_exists(self, measure ) :
 159        m = self._complexity.get( measure, None )
 160        return m
 161
 162    def set_complexity_measure(self, measure, value ) :
 163        self._complexity[ measure ] = value
 164
 165    #-------------------------------------------------------#
 166    #    So that MappingProxyType doesn't breaks deepcopy   #
 167    #-------------------------------------------------------#
 168
 169    def __getstate__( self ) :
 170        state = self.__dict__.copy()
 171        state[ 'symbol_idx_map' ] = dict( self.symbol_idx_map )
 172        state[ 'state_idx_map'  ] = dict( self.state_idx_map  )
 173        return state
 174
 175    def __setstate__( self, state ) :
 176        state[ 'symbol_idx_map' ] = MappingProxyType( state[ 'symbol_idx_map' ] )
 177        state[ 'state_idx_map'  ] = MappingProxyType( state[ 'state_idx_map'  ] )
 178        self.__dict__.update( state )
 179
 180    #-------------------------------------------------------#
 181    #                     Properties                        #
 182    #-------------------------------------------------------#
 183
 184    @property
 185    def is_q_weighted(self) :
 186        return self._has_valid_rational_probabilities
 187
 188    #-------------------------------------------------------#
 189    #                     Serialization                     #
 190    #-------------------------------------------------------#
 191
 192    def from_json_dict( self, config : dict[str, Any] ) :
 193
 194        self.name             = config.get( "name", "" )
 195        self.description      = config.get( "description", "" )
 196        self.start_state     = config.get( "start_state", 0 )
 197        
 198        json_states      = config.get( "states",      [] )
 199        json_transitions = config.get( "transitions", [] )
 200
 201        states=[ 
 202            CausalState.from_json_dict( state )
 203            for state in json_states
 204        ]
 205
 206        transitions=[ 
 207            Transition.from_json_dict( tr )
 208            for tr in json_transitions
 209        ]
 210
 211        self.alphabet = config.get( "alphabet", () )
 212
 213        self.states      = tuple( states )
 214        self.transitions = tuple( transitions )
 215
 216        self.update_symbol_idx_map()
 217        self.update_state_idx_map()
 218
 219    def to_json_dict(self) -> dict[ str, Any ]:
 220
 221        """Create a dict representing the HMM configuration.
 222
 223        Returns:
 224
 225            dict[str,any]: Dictionary containing, name, description, states, transitions, alphabet.
 226        """
 227
 228        return {
 229            "name"            : self.name,
 230            "description"     : self.description,
 231            "start_state"     : self.start_state,
 232            "states"          : [ state.to_json_dict()      for state      in self.states      ],
 233            "transitions"     : [ transition.to_json_dict() for transition in self.transitions ],
 234            "alphabet"        : list(self.alphabet)
 235        }
 236
 237    def save_config(
 238        self, 
 239        output_dir : Path | str, 
 240        with_complexity : bool = False, 
 241        with_non_trivial_complexity : bool = False ) :
 242
 243        output_dir = Path( output_dir )
 244
 245        config = self.to_json_dict()
 246
 247        if with_complexity :
 248            
 249            complexity = self.get_complexities( 
 250                with_non_trivial=with_non_trivial_complexity
 251            )
 252
 253            config[ "complexity" ] = complexity
 254
 255        config[ "structural_properties" ] = {
 256            "unifilar"              : self.is_unifilar(),
 257            "row_stochastic"        : self.is_row_stochastic(),
 258            "strongly_connected"    : self.is_strongly_connected(),
 259            "aperiodic"             : self.is_aperiodic() #,
 260            # "minimal"               : self._is_minimal_as_dfa( topological_only=False )
 261        }
 262
 263        with open( output_dir / "am_config.json", "w", encoding="utf-8" ) as f :
 264            json.dump( config, f, ensure_ascii=False, indent=2, default=list )
 265
 266    def from_file( self, path : Path ) :
 267        with open( path / "am_config.json", "r" ) as f:
 268            config = json.load(f)
 269        self.from_json_dict( config )
 270
 271    #-------------------------------------------------------#
 272    #                   Get and Compute                     #
 273    #-------------------------------------------------------#
 274
 275    def get_transition_list(self) -> list[list[tuple[int, float, int]]] :
 276        trs = [ [] for _ in range( len( self.states ) ) ]
 277        for tr in self.transitions :
 278            trs[ tr.origin_state_idx ].append( ( 
 279                tr.symbol_idx, 
 280                float( tr.prob ),
 281                tr.target_state_idx ) )
 282        return trs
 283
 284    def get_complexities( 
 285        self, 
 286        with_non_trivial=False ) :
 287
 288        trivial = [
 289            self.C_mu,
 290            self.h_mu,
 291            self.H_1,
 292            self.rho_mu
 293        ]
 294
 295        non_trivial = [
 296            self.E, 
 297            self.T_inf,
 298            self.S,
 299            self.chi
 300        ]
 301            
 302        complexities = { m.__name__ : m() for m in trivial }
 303
 304        if with_non_trivial :
 305
 306            complexities |= { m.__name__ : float( m() ) for m in non_trivial }
 307
 308            _ = self.block_convergence()
 309
 310            scalar_complexity_keys = {
 311                "E",    
 312                "S",    
 313                "T_inf"
 314            }
 315
 316            complexities[ "block" ] = {}
 317            for key in scalar_complexity_keys :
 318                c = self.get_complexity_measure_if_exists( key )
 319                if c is not None :
 320                    complexities[ "block" ][ key ] = float( c )
 321
 322            block_complexity_keys = {
 323                "E_L",   
 324                "T_L",   
 325                "S_L",   
 326                "H_L",   
 327                "h_mu_L",
 328                "H_sync"
 329            }
 330
 331            for key in block_complexity_keys :
 332                bc = self.get_complexity_measure_if_exists( key )
 333                if bc is not None :
 334                    complexities[ "block" ][ key ] = [ float(x) for x in bc ]
 335
 336        return complexities
 337
 338    #-------------------------------------------------------#
 339
 340    def get_metadata(self) :
 341
 342        C = self.get_complexities( with_non_trivial=False )
 343
 344        return {
 345            "name" : self.name,
 346            'complexity'  : self._complexity,
 347            "description" : self.description
 348        }
 349
 350    def get_transition_matrix(self) :
 351
 352        if self._T  is not None :
 353            return self._T
 354
 355        n_states = len( self.states )
 356        T = np.zeros((n_states, n_states))
 357
 358        for tr in self.transitions :    
 359            T[ tr.origin_state_idx, tr.target_state_idx  ] = tr.prob
 360
 361        self._T = T
 362
 363        return self._T
 364
 365    #-------------------------------------------------------#
 366
 367    def get_T_X(self) :
 368
 369        if self._T_x  is not None :
 370            return self._T_x
 371
 372        n_states  = len( self.states )
 373        n_symbols = len( self.alphabet )
 374
 375        T_x = [ np.zeros( ( n_states, n_states) ) for _ in range( n_symbols ) ]
 376
 377        for tr in self.transitions :
 378            T_x[ tr.symbol_idx ][tr.origin_state_idx, tr.target_state_idx] = tr.prob
 379
 380        self._T_x = T_x
 381        return self._T_x
 382
 383    #-------------------------------------------------------#
 384
 385    def get_msp_qw(
 386        self,
 387        exact_state_cap: int = 1000,
 388        verbose: bool = True,
 389    ) -> MSP :
 390        if self._msp is not None:
 391            return self._msp
 392
 393        try : 
 394
 395            print( "\nTrying to Compute Mixed State Presentation using Exact Fractions\n" )
 396
 397            self._msp = compute_msp_exact(
 398                T_x=self.get_Tx_fractional(),
 399                pi=self.get_fractional_stationary_distribution(),
 400                n_states=len(self.states),
 401                alphabet=self.alphabet,
 402                exact_state_cap=exact_state_cap,
 403                verbose=verbose
 404            )
 405
 406            return self._msp 
 407
 408        except RuntimeError as e :
 409            warnings.warn( f"Exact msp failed: {e} Falling back to msp approximation." )
 410
 411        return self.get_msp()
 412
 413    def get_msp(
 414        self,
 415        exact_state_cap: int = 1_250_000,
 416        verbose = True,
 417    ) -> MSP :
 418
 419        if self._msp is not None:
 420            return self._msp
 421     
 422        T_x = self.get_T_X()
 423        pi  = self.get_stationary_distribution()
 424    
 425        print( "\nComputing Mixed State Presentation..." )
 426
 427        self._msp = compute_msp( 
 428            T_x=T_x,
 429            pi=pi,
 430            n_states=len(self.states),
 431            alphabet=self.alphabet,
 432            exact_state_cap=exact_state_cap,
 433            verbose=verbose
 434        )
 435
 436        return self._msp
 437
 438    def get_reverse_am(self) :
 439
 440        was_q_weighted = self._has_valid_rational_probabilities
 441
 442        if self._reverse_am is not None:
 443            return self._reverse_am
 444
 445        pi = self.get_stationary_distribution()
 446        self._reverse_am = copy.deepcopy(self)
 447        
 448        new_transitions = []
 449        for tr in self.transitions:
 450            i = tr.target_state_idx
 451            j = tr.origin_state_idx
 452            
 453            p_reversed = (pi[j] * tr.prob) / pi[i]
 454            
 455            new_transitions.append(
 456                tr.modified_deep_copy(
 457                    origin_state_idx=i,
 458                    target_state_idx=j,
 459                    prob=p_reversed,
 460                    pq=None
 461                )
 462            )
 463
 464        self._reverse_am.set_transitions(new_transitions)
 465
 466        if self._reverse_am.is_epsilon_machine():
 467            return self._reverse_am
 468
 469        rmsp = self._reverse_am.get_msp_qw( exact_state_cap=len(self.states)*4 )
 470
 471        self._reverse_am.set_states( rmsp.states )
 472        self._reverse_am.set_transitions( rmsp.transitions )
 473        self._reverse_am._msp = rmsp
 474        self._reverse_am.start_state = 0
 475
 476        self._reverse_am.collapse_to_largest_strongly_connected_subgraph()
 477        self._reverse_am.minimize()
 478
 479        if was_q_weighted :
 480            self._reverse_am.to_q_weighted()
 481
 482        return self._reverse_am
 483
 484    #-------------------------------------------------------#
 485
 486    def get_Tx_fractional(self) -> list[ list[ list[ Fraction ] ] ] :
 487
 488        self.to_q_weighted()
 489
 490        n_states  = len( self.states )
 491        n_symbols = len( self.alphabet )
 492
 493        T_x = []
 494
 495        for x in range( n_symbols ) :
 496            T_x.append( [] )
 497            for i in range( n_states ) :
 498                T_x[ x ].append( [ 0 for _ in range( n_states ) ] )
 499
 500        for tr in self.transitions :
 501            T_x[ tr.symbol_idx ][ tr.origin_state_idx ][ tr.target_state_idx ] = tr.pq
 502
 503        return T_x
 504
 505    def get_T_sympy( self ) :
 506
 507        self.to_q_weighted()
 508
 509        n = len( self.states )
 510        T = sympy.zeros( n, n )
 511
 512        for tr in self.transitions :
 513            T[ tr.origin_state_idx, tr.target_state_idx ] = tr.pq
 514
 515        return T
 516
 517    def get_fractional_stationary_distribution(self) :
 518
 519        T = self.get_T_sympy()
 520
 521        if self._pi_fractional is not None :
 522            return self._pi_fractional
 523
 524        G = self.as_digraph()
 525
 526        if not nx.is_strongly_connected(G):
 527            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
 528
 529        self._pi_fractional = solve_for_pi_fractional( T )
 530
 531        return self._pi_fractional
 532
 533    def get_stationary_distribution(self):
 534
 535        if self._pi is not None :
 536            return self._pi
 537
 538        G = self.as_digraph()
 539        
 540        if not nx.is_strongly_connected(G):
 541            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
 542
 543        T = self.get_transition_matrix()
 544        return solve_for_pi( T )		
 545
 546    #-------------------------------------------------------#
 547    #                 Complexity Measures                   #
 548    #-------------------------------------------------------#
 549    
 550    def C_mu( self ) :
 551
 552        """The *statistical complexity* (aka *forecasting complexity*) :
 553
 554        .. math::
 555
 556            C_{\\mu} = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\log_2 \\Pr(\\sigma),
 557
 558        where :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
 559
 560        .. note::
 561
 562            **Interpretations**
 563
 564            * The amount of historical information a process stores.
 565            * The amount of structure in a process.
 566
 567        Returns:
 568
 569            float: :math:`C_{\\mu}`.
 570
 571        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 572            Decomposition of Intrinsic Computation*, 2016.
 573            <https://arxiv.org/abs/1309.3792>
 574        """
 575
 576        m = self.get_complexity_measure_if_exists( "C_mu" )
 577
 578        if m is not None :
 579            return m
 580
 581        pi = self.get_stationary_distribution()
 582
 583        h = 0
 584        for i, pr in enumerate( pi ) :
 585            
 586            if pr < self._EPS :
 587                continue
 588
 589            h += -pr * np.log2( pr )
 590
 591        self.set_complexity_measure( "C_mu", h )
 592
 593        return h
 594
 595    #-------------------------------------------------------#
 596
 597    def h_mu( self ) :
 598
 599        """The *entropy rate* :
 600
 601        .. math::
 602
 603            h_{\\mu}(\\boldsymbol{\\mathcal{S}}) = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\sum_{x \\in \\mathcal{A}} \\Pr(x|\\sigma) \\log_2 \\Pr(x|\\sigma),
 604
 605        where :math:`\\mathcal{A}` is the alphabet and :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
 606
 607        .. note::
 608
 609            **Interpretations**
 610
 611            * The lower bound on achievable loss in bits. 
 612            * The irreducable randomness in the process.
 613            * The intrinsic Randomness in the process.
 614
 615        Returns:
 616            
 617            float: :math:`h_{\\mu}`.
 618
 619        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 620            Decomposition of Intrinsic Computation*, 2016.
 621            <https://arxiv.org/abs/1309.3792>
 622        """
 623
 624        m = self.get_complexity_measure_if_exists( "h_mu" )
 625
 626        if m is not None :
 627            return m
 628
 629        T  = self.get_transition_matrix()
 630        pi = self.get_stationary_distribution()
 631
 632        n_states = pi.size
 633
 634        h = 0
 635        for i, pr in enumerate( pi ) :
 636
 637            if pr < self._EPS :
 638                continue
 639
 640            row_entropy = 0
 641            for j in range( len( pi ) ) :
 642
 643                if T[ i, j ]  < self._EPS :
 644                    continue
 645
 646                row_entropy -= T[ i, j ] * np.log2( T[ i, j ] )
 647
 648            h += pr * row_entropy
 649
 650        self.set_complexity_measure( "h_mu", h )
 651
 652        return h
 653
 654    #-------------------------------------------------------#
 655
 656    def H_1(self) -> float :
 657
 658        """The *single symbol uncertainty*:
 659
 660        .. math::
 661
 662            H(1)=-\\sum_{x\\in\\mathcal{A}} \\Pr(x) \\log_2{\\Pr(x)},
 663
 664        where :math:`\\mathcal{A}` is the alphabet [^James_2018], p.2.
 665
 666        .. note::
 667
 668            **Interpretations**
 669
 670            * How uncertain you are on average about a single measurement with no context.
 671
 672        Returns:
 673
 674            float: :math:`H(1)`.
 675
 676        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
 677            <https://arxiv.org/abs/1105.2988>
 678        """
 679
 680        m = self.get_complexity_measure_if_exists("H_1")
 681        if m is not None:
 682            return m
 683
 684        pi  = self.get_stationary_distribution()
 685        T_X = self.get_T_X()  # dict: symbol -> matrix
 686
 687        h = 0.0
 688        for T_x in T_X:
 689            # Pr(x) = sum_i pi[i] * sum_j T^(x)[i,j]
 690            p_sym = 0.0
 691            for i, pr in enumerate(pi):
 692                if pr < self._EPS:
 693                    continue
 694                p_sym += pr * T_x[i, :].sum()
 695
 696            if p_sym < self._EPS:
 697                continue
 698            h -= p_sym * np.log2(p_sym)
 699
 700        self.set_complexity_measure("H_1", h)
 701        return h
 702
 703    #-------------------------------------------------------#
 704
 705    def rho_mu(self) -> float :
 706        
 707        """The *anticipated information* [^James_2018], p.3.:
 708
 709        .. math::
 710
 711            \\rho_{\\mu}= H(1) - h_{\\mu}
 712
 713        Returns:
 714            
 715            float: :math:`\\rho_{\\mu}`
 716
 717        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
 718            <https://arxiv.org/abs/1105.2988>
 719        """
 720
 721        m = self.get_complexity_measure_if_exists("rho_mu")
 722        
 723        if m is not None:
 724            return m
 725
 726        rho = self.H_1() - self.h_mu()
 727        
 728        self.set_complexity_measure("rho_mu", rho)
 729        
 730        return rho
 731
 732    #-------------------------------------------------------#
 733
 734    def block_convergence( self )  :
 735
 736        """
 737        Run [block entropy convergence](am_fast.html#block_entropy_convergence). Estimates [$\\mathbf{E}$](am_hmm.html#HMM.E), [$\\mathbf{S}$](am_hmm.html#HMM.S), [$\\mathbf{T}$](am_hmm.html#HMM.T_inf), and block measures[^crutchfield_exact_2016]:
 738
 739        - $\\mathbf{E}(L) = H(L) - L \\cdot h_{\\mu}$, 
 740
 741        - $\\mathbf{T}(L) = \\sum_{l=1}^{L} l \\left[ h_{\\mu}(l) - h_{\\mu} \\right]$, 
 742
 743        - $\\mathbf{S}(L) = \\sum_{l=0}^{L} \\mathcal{H}(l)$, 
 744
 745        - $H(L) = H[X_{0:L}]$, 
 746
 747        - $h_{\\mu}(L) = H(L) - H(L-1)$, and 
 748
 749        - $\\mathcal{H}(L) = -\\sum_{w \\in \\mathcal{A}^L} Pr(w) \\sum_{\\sigma \\in \\mathcal{S}} Pr(\\sigma|w) \\log_2 Pr(\\sigma|w)$.
 750
 751        You can plot these curves, and the block entropy curves using, [`amachine.HMM.draw_block_measure_curves`](am_hmm.html#HMM.draw_block_measure_curves), and [`amachine.HMM.draw_block_entropy_curve`](am_hmm.html#HMM.draw_block_entropy_curve). 
 752
 753        <img src="../resources/curves.png" alt="block measures plots" style="width: 100%; margin-left: 0%;">
 754
 755        Returns:
 756        
 757            ComplexityMeasures: An object containing the estimated measures with the following attributes:
 758            
 759            - E (float): The excess entropy.
 760            - T_inf (float): The transient information ($\\mathbf{T}$).
 761            - S (float): The synchronization information.
 762            - E_L (numpy.ndarray): The block excess entropy ($\\mathbf{E}(L)$).
 763            - T_L (numpy.ndarray): The block transient information ($\\mathbf{T}(L)$).
 764            - S_L (numpy.ndarray): The block synchronization information ($\\mathbf{S}(L)$).
 765            - H_L (numpy.ndarray): The block entropy ($H(L)$).
 766            - h_mu_L (numpy.ndarray): The entropy rate estimates ($h_{\\mu}(L)$).
 767            - H_sync (numpy.ndarray): The state-block synchronization ($\\mathcal{H}(L)$).
 768            - converged (bool): True if the algorithm converged.
 769
 770        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 771            Decomposition of Intrinsic Computation*, 2016.
 772            <https://arxiv.org/abs/1309.3792>
 773        """
 774
 775        trs = [ [] for _ in range( len( self.states ) ) ]
 776        for tr in self.transitions :
 777            trs[ tr.origin_state_idx ].append( ( 
 778                tr.symbol_idx, 
 779                float( tr.prob ),
 780                tr.target_state_idx ) )
 781
 782        pi = self.get_stationary_distribution()
 783
 784        state_dist = [ float( pi[ i ] ) for i in range( len( self.states ) ) ]
 785        branches = [(1.0, list(state_dist))]
 786
 787        print( "\nComputing Block Entropy\n" )
 788
 789        C = am_fast.block_entropy_convergence(
 790            h_mu            = self.h_mu(),
 791            n_states        = len( self.states ),
 792            n_symbols       = len( self.alphabet ),
 793            convergence_tol = 1e-8,
 794            precision       = 15,
 795            eps             = 1e-25,
 796            branches        = branches,
 797            trans           = trs,
 798            max_branches    = 30_000_000
 799        )
 800
 801        print( "Done\n" )
 802
 803        self.set_complexity_measure( f"E",       C.E )
 804        self.set_complexity_measure( f"S",       C.S )
 805        self.set_complexity_measure( f"T_inf",   C.T )
 806        self.set_complexity_measure( f"E_L",     C.E_L.tolist() )
 807        self.set_complexity_measure( f"T_L",     C.T_L.tolist() )
 808        self.set_complexity_measure( f"S_L",     C.S_L.tolist() )
 809        self.set_complexity_measure( f"H_L",     C.H_L.tolist() )
 810        self.set_complexity_measure( f"h_mu_L",  C.h_mu_L.tolist() )
 811        self.set_complexity_measure( f"H_sync",  C.H_sync.tolist() )
 812
 813        return C
 814
 815    #-------------------------------------------------------#
 816
 817    def E( self ) -> float :
 818
 819        """The *excess entropy* [^crutchfield_exact_2016], p.4:
 820
 821        .. math::
 822
 823            \\mathbf{E} \\equiv \\sum_{L=1}^{\\infty} I[X_{-\\infty:0}; X_{0:\\infty}]
 824        
 825        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 826
 827        .. note::
 828
 829            **Interpretations**
 830
 831            * The information from the past that reduces uncertainty in the future [^crutchfield_exact_2016].
 832            * How much information an observer must extract to synchronize to the process.
 833            * Measures how long the process appears more complex than it asymptotically is.
 834            * Vanishes for immediately synchronizable processes.
 835
 836        Returns:
 837        
 838            float: :math:`\\mathbf{E}`
 839
 840        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 841            Decomposition of Intrinsic Computation*, 2016.
 842            <https://arxiv.org/abs/1309.3792>
 843        """
 844
 845        m = self.get_complexity_measure_if_exists( "E" )
 846
 847        if m is not None :
 848            return m
 849
 850        try : 
 851            msp = self.get_msp()
 852            E, S, T = msp.get_E_S_T()
 853            self.set_complexity_measure( "E", E )
 854            self.set_complexity_measure( "S", S )
 855            self.set_complexity_measure( "T_inf", T )
 856            
 857        except Exception as e :
 858
 859            print( f"MSP failed {e}" )
 860
 861            C = self.block_convergence()	
 862            E = C.E
 863            self.set_complexity_measure( "E", E )
 864
 865        return E
 866
 867    #-------------------------------------------------------#
 868
 869    def S( self ) -> float :
 870
 871        """The *synchronization* information:
 872
 873        .. math::
 874
 875            \\mathbf{S} \\equiv \\sum_{L=1}^{\\infty} \\mathcal{H}(L),
 876
 877        where :math:`\\mathcal{H}(L)` is the average state uncertainty having seen all length-L words [^crutchfield_exact_2016], p.4.
 878
 879        .. note::
 880
 881            **Interpretations**
 882
 883            * The total amount of state information that an observer must extract to become synchronized [^crutchfield_exact_2016].
 884
 885        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 886
 887        Returns:
 888        
 889            float: :math:`\\mathbf{S}`
 890
 891        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 892            Decomposition of Intrinsic Computation*, 2016.
 893            <https://arxiv.org/abs/1309.3792>
 894        """
 895
 896        m = self.get_complexity_measure_if_exists( "S" )
 897
 898        if m is not None :
 899            return m
 900
 901        try : 
 902            msp = self.get_msp()
 903            E, S, T = msp.get_E_S_T()
 904            self.set_complexity_measure( "E", E )
 905            self.set_complexity_measure( "S", S )
 906            self.set_complexity_measure( "T_inf", T )
 907
 908        except Exception as e :
 909            print( f"{e} \nFalling back to iterative estimation.")
 910            C = self.block_convergence()	
 911            S = C.S
 912            self.set_complexity_measure( "S", S )
 913
 914        return S
 915
 916    #-------------------------------------------------------#
 917
 918    def T_inf( self ) -> float :
 919
 920        """The *transient information*[^crutchfield_exact_2016], p.4:
 921
 922        .. math::
 923
 924            \\mathbf{T} \\equiv \\sum_{L=1}^{\\infty} L \\left[ h_{\\mu}(L) - h_{\\mu} \\right]
 925
 926        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
 927
 928        .. note::
 929
 930            **Interpretations**
 931
 932            * The amount of information one must extract from observations so that the block entropy converges to its linear asymptote[^crutchfield_exact_2016].
 933
 934        Returns:
 935        
 936            float: :math:`\\mathbf{T}`
 937
 938        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
 939            Decomposition of Intrinsic Computation*, 2016.
 940            <https://arxiv.org/abs/1309.3792>
 941        """
 942
 943        m = self.get_complexity_measure_if_exists( "T_inf" )
 944
 945        if m is not None :
 946            return m
 947
 948        try : 
 949            msp = self.get_msp()
 950            E, S, T = msp.get_E_S_T()
 951            self.set_complexity_measure( "E", E )
 952            self.set_complexity_measure( "S", S )
 953            self.set_complexity_measure( "T_inf", T )
 954            T_inf = T
 955
 956        except Exception as e :
 957            print( f"{e} \nFalling back to iterative estimation.")
 958            C = self.block_convergence()	
 959            T_inf = C.T
 960
 961        return T_inf
 962
 963    #-------------------------------------------------------#
 964
 965    def chi( self ) -> float :
 966
 967        """The foward crypticity[^crutchfield_crypticity_2009][^Mahoney_crypticity_2021], p.2:
 968
 969        .. math::
 970
 971            \\chi = C_{\\mu} - \\mathbf{E}
 972
 973        :math:`C_{\\mu}` is trivially computed from the stationary distribution in :meth:`C_mu` and :math:`\\mathbf{E}` in :meth:`E`.
 974
 975        .. note::
 976
 977            **Interpretations**
 978
 979            * Difference between internal stored information and apparent information to an observer.
 980            * How muching information is hiding in the system.
 981
 982        Returns:
 983        
 984            float: :math:`\\chi`
 985
 986        [^crutchfield_crypticity_2009]: Crutchfield et al., Time’s barbed arrow: Irreversibility, crypticity, and stored information, 2009.
 987            <https://arxiv.org/abs/0902.1209>
 988
 989        [^Mahoney_crypticity_2021]: Mahoney et al., Information Accessibility and Cryptic Processes, 2021.
 990            <https://arxiv.org/abs/0905.4787>
 991        """
 992
 993        m = self.get_complexity_measure_if_exists( "chi" )
 994
 995        if m is not None :
 996            return m
 997
 998        chi = self.C_mu() - self.E()
 999
1000        if chi < 0 :
1001            
1002            # if chi is 0, accumulated floating point error can result in small negative values
1003            if chi < -1e-5:
1004                warnings.warn(f"Crypticity is negative ({chi:.6e}).")
1005            
1006            chi = np.clip( chi, 0 )
1007
1008        self.set_complexity_measure( "chi", chi )
1009
1010        return chi
1011
1012    #-------------------------------------------------------#
1013    #                      Properties                       #
1014    #-------------------------------------------------------#
1015
1016    def is_row_stochastic(self) :
1017
1018        """
1019        Check that all states have outgoing transition probabilities that sum to 1.
1020        """
1021
1022        sums = np.zeros( len( self.states ) )
1023        for tr in self.transitions :
1024            sums[ tr.origin_state_idx ] += tr.prob
1025
1026        return np.allclose( sums, 1.0 )
1027
1028    #-------------------------------------------------------#
1029
1030    def is_unifilar(self):
1031        """
1032        Check that no state emits the same symbol on transitions to different states.
1033        """
1034
1035        seen_state_symbols = set()
1036        for tr in self.transitions:
1037
1038            sym_key = (tr.origin_state_idx, tr.symbol_idx)
1039
1040            # If we've seen this origin+symbol or origin+target before
1041            if sym_key in seen_state_symbols :
1042                return False
1043
1044            seen_state_symbols.add(sym_key)
1045
1046        return True
1047
1048    #-------------------------------------------------------#
1049
1050    def is_strongly_connected(self) :
1051
1052        """
1053        Check if every state is reachable from every other state. Relies on [nx.is_strongly_connected](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.components.is_strongly_connected.html).
1054        """
1055
1056        return nx.is_strongly_connected( self.as_digraph() )
1057
1058    #-------------------------------------------------------#
1059
1060    def is_aperiodic(self) :
1061
1062        """
1063        Checks if machine is periodic. Relies on [nx.is_aperiodic](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.dag.is_aperiodic.html), "A strongly connected directed graph is aperiodic if there is no integer k > 1 that divides the length of every cycle in the graph."
1064        """
1065
1066        return nx.is_aperiodic( self.as_digraph() )
1067
1068    #-------------------------------------------------------#
1069
1070    def _is_minimal_as_dfa( self, topological_only : bool, verbose=True ) :
1071
1072        with_probs = not topological_only
1073
1074        # Construct the DFA
1075        dfa = self.as_dfa( with_probs=with_probs )
1076
1077        # Minimize the DFA
1078        #dfa = dfa.minify(retain_names=True)
1079        dfa = am_fast.minify_cpp( dfa, retain_names=True )
1080
1081        # check we have minimal number of states
1082        if len( dfa.states ) != len( self.states ) :
1083            if verbose : 
1084                print( f"Not minimal reduces from {len( self.states )} to {len( dfa.states )} states" )
1085            return False
1086
1087        return True
1088
1089    def is_topological_epsilon_machine( self, verbose=True ) :
1090
1091        """
1092        Checks if the HMM is a topological $\\epsilon$-machine [^1].
1093
1094        [^1]: Johnson et al, Enumerating Finitary Processes, 2024.
1095            <https://arxiv.org/abs/1011.0036>
1096        """
1097
1098        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1099            if verbose : 
1100                print( f"Either non unifilar or not strongly connected" )
1101            return False
1102        else :
1103            return self._is_minimal_as_dfa( topological_only=True, verbose=verbose )
1104
1105    def is_epsilon_machine( self, verbose=True ) :
1106
1107        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1108            if verbose : 
1109                print( f"Either non unifilar or not strongly connected" )
1110            return False
1111        else :
1112            return self._is_minimal_as_dfa( topological_only=False, verbose=verbose )
1113
1114    #-------------------------------------------------------#
1115    #                 Structural Operations                 #
1116    #-------------------------------------------------------#
1117
1118    def minimize(self, retain_names: bool = True, verbose=False):
1119
1120        """
1121        Minimizes the HMM, resulting in an :math:`\\epsilon-`machine if the HMM
1122        is unifilar and strongly connected. Converts the HMM to a DFA with symbols
1123        labeled jointly with symbols and probabilities, and uses Myhill-Nerode 
1124        equivalence for minimization. Relies on `automata_lib` and uses
1125         `automata.fa.dfa.DFA.minify` with `allow_partial=True`, and all states
1126         final.
1127
1128        Args:
1129            retain_names (bool): If `True`, the merged states will be named by their union, e.g. `{s_0, s_1}`, and other states will retain their origion names. Otherwise, they will be relabled `{ '0', '1', ..., 'n-1' }`.
1130
1131        Returns:
1132        
1133            automata.fa.dfa.DFA : the resulting DFA.
1134        """
1135
1136        if self._already_minimized :
1137            return
1138
1139        start = time.perf_counter()
1140
1141        if not self.is_unifilar():
1142            raise ValueError(
1143                "DFA minimization is not valid for non-unifilar HMMs"
1144            )
1145
1146        was_strongly_connected = self.is_strongly_connected()
1147        was_row_stochastic     = self.is_row_stochastic()
1148        was_unifilar           = self.is_unifilar()
1149
1150        n_states_before = len(self.states)
1151
1152        dfa = self.as_dfa(with_probs=True)
1153
1154        min_dfa = self.as_dfa(with_probs=True).minify(retain_names=True)
1155        #min_dfa = am_fast.minify_cpp( dfa, retain_names=True )
1156
1157        # Build lookup from original state index -> CausalState object
1158        orig_state   = {i: s for i, s in enumerate(self.states)}
1159        eq_list      = list(min_dfa.states)
1160
1161        start_eq = min_dfa.initial_state
1162        
1163        # Separate the start state, then sort the rest by the 
1164        # smallest original state index inside each equivalence class.
1165        other_eqs = [eq for eq in eq_list if eq != start_eq]
1166        other_eqs.sort(key=lambda eq: min(eq))
1167
1168        # Recombine so start eq comes first, followed by the sorted remaining classes
1169        eq_list = [start_eq] + other_eqs
1170        # ----------------------------------------------------------
1171
1172        # Recompute eq_to_idx with the new ordering
1173        eq_to_idx = {eq: i for i, eq in enumerate(eq_list)}
1174
1175        # new_start is now guaranteed to be 0
1176        new_start = 0
1177
1178        # Map each original state index -> its equivalence class
1179        # Guard: minify() silently drops unreachable states
1180        orig_to_eq = {s: eq for eq in min_dfa.states for s in eq}
1181
1182        # Build lookup from original state index -> its transitions
1183        orig_trs = defaultdict(list)
1184        for t in self.transitions:
1185            orig_trs[t.origin_state_idx].append(t)
1186
1187        new_trs = []
1188        for eq in min_dfa.states:
1189            rep        = next(iter(eq))
1190            origin_idx = eq_to_idx[eq]
1191            for t in orig_trs[rep]:
1192
1193                target_eq  = orig_to_eq[t.target_state_idx]
1194                target_idx = eq_to_idx[target_eq]
1195                
1196                new_trs.append(
1197                    t.modified_deep_copy(
1198                        origin_state_idx = origin_idx,
1199                        target_state_idx = target_idx
1200                    )
1201                )
1202
1203        members_list = [[orig_state[i] for i in sorted(eq)] for eq in eq_list]  # sorted for determinism
1204
1205        # Compute new names
1206        if retain_names:
1207            new_names = [
1208                "{" + ",".join(str(m.name) for m in members) + "}" if len(members) > 1
1209                else members[0].name
1210                for members in members_list
1211            ]
1212        else:
1213            new_names = [str(j) for j in range(len(eq_list))]
1214
1215        old_name_to_new_name = {
1216            m.name: new_names[j]
1217            for j, members in enumerate(members_list)
1218            for m in members
1219        }
1220
1221        # Build the new states, preserving classes and isomorphs regardless of naming
1222        new_states = []
1223        for j, (eq, members, name) in enumerate(zip(eq_list, members_list, new_names)):
1224            
1225            classes : defaultdict[int,set[str]]= {}
1226            for m in members:
1227                classes |= m.classes
1228            
1229            isomorphs = {
1230                old_name_to_new_name.get(iso, iso)
1231                for m in members
1232                for iso in m.isomorphs
1233                if old_name_to_new_name.get(iso, iso) != name
1234            }
1235            
1236            pseudo_isomorphs = {
1237                old_name_to_new_name.get(iso, iso)
1238                for m in members
1239                for iso in m.pseudo_isomorphs
1240                if old_name_to_new_name.get(iso, iso) != name
1241            }
1242
1243            new_states.append(CausalState(
1244                name      = name,
1245                classes   = classes,
1246                isomorphs = isomorphs,
1247                pseudo_isomorphs = pseudo_isomorphs
1248            ))
1249
1250        self.set_states(new_states)
1251        self.set_transitions(new_trs)
1252        self.start_state = new_start
1253
1254        if n_states_before == len(new_states) and verbose :
1255            print( f"{n_states_before} state HMM was already minimal.\n" )
1256        elif verbose :
1257            print( f"Minimized from {n_states_before} to {len(new_states)}\n" )
1258
1259        if not ( was_strongly_connected ==  self.is_strongly_connected() ) :
1260            raise RuntimeError(
1261                f"Minimization broke strongly connected"
1262            )
1263
1264        if not ( was_row_stochastic ==  self.is_row_stochastic() ) :
1265            raise RuntimeError(
1266                f"Minimization broke row stochasticity"
1267            )
1268
1269        if not( was_unifilar == self.is_unifilar() ) :
1270            raise RuntimeError(
1271                f"Minimization broke unifilarity"
1272            )
1273
1274        self._already_minimized = True
1275
1276    #-------------------------------------------------------#
1277    #                      Modifiers                        #
1278    #-------------------------------------------------------#
1279
1280
1281    def collapse_to_largest_strongly_connected_subgraph( self, rename_states=True ) :
1282
1283        was_q_weighted = self._has_valid_rational_probabilities
1284
1285        # get equivalent networkx graph
1286        G = self.as_digraph()
1287
1288        # if already strongly connected, nothing to do
1289        if not nx.is_strongly_connected( G ) :
1290
1291            start = time.perf_counter()
1292            subgraph_nodes = list( nx.strongly_connected_components( G ) )
1293
1294            # decompose into strongly connected components and sort by length
1295            # subgraph_nodes = list(nx.strongly_connected_components( G ))
1296            subgraph_nodes.sort(key=len)
1297            component_state_set = subgraph_nodes[-1]
1298
1299            # Take the largest strongly connected component (as list of state names)
1300            component_states = sorted( list( component_state_set ) )
1301
1302            # make temporary copies of the old transitions and states
1303            old_transitions = [
1304                tr.deepcopy()
1305                for tr in self.transitions
1306            ]
1307
1308            old_states = [
1309                s.deepcopy()
1310                for s in self.states
1311            ]
1312
1313            self.set_states(
1314                states=[ 
1315                    state
1316                    for i, state in enumerate( old_states ) if i in component_state_set
1317                ]
1318            )
1319
1320            # we will build new transition list based on those belonging to the component
1321            self.set_transitions( transitions= [] )
1322
1323            # for tracking which new transitions leave each state
1324            transitions_from_state = { state : set() for state in component_states }
1325            new_transitions = []
1326
1327            for tr in old_transitions :
1328
1329                origin_state_name = old_states[ tr.origin_state_idx ].name
1330                target_state_name = old_states[ tr.target_state_idx ].name
1331
1332                # skip transitions that connect separate strongly connected components
1333                if not ( tr.origin_state_idx in component_state_set and tr.target_state_idx in component_state_set ) :
1334                    continue
1335
1336                # track transitions (by index in new transitions list) that leave this state
1337                transitions_from_state[ tr.origin_state_idx ].add( len( new_transitions ) )
1338
1339                my_origin_state_idx = self.state_idx_map[ origin_state_name ]
1340                my_target_state_idx = self.state_idx_map[ target_state_name ]
1341
1342                new_transitions.append( 
1343                    tr.modified_deep_copy(  
1344                        origin_state_idx=my_origin_state_idx,
1345                        target_state_idx=my_target_state_idx
1346                    )
1347                )
1348
1349            self.set_transitions( transitions=new_transitions )
1350
1351            # if we removed an outgoing transition from a state, we need to distribute its probability 
1352            # among the remaining outgoing transitions from the state
1353            
1354            transition_list = list( self.transitions )
1355            
1356            for state in component_states :
1357                
1358                # get the set of transitions leaving this state
1359                state_trs = transitions_from_state[ state ]
1360
1361                # sum the probabilities of the outgoing transitions from the state
1362                p_sum = np.sum( [ self.transitions[ i ].prob for i in state_trs ] )
1363
1364                # how much probability is missing
1365                diff = 1.0 - p_sum
1366
1367                # if significant difference
1368                if abs( diff ) > self._EPS : 
1369
1370                    # calculate how much of the difference each transition gets
1371                    adjustment = diff / len( state_trs )
1372                    
1373                    new_transitions = []
1374
1375                    # update the transitions
1376                    for i in state_trs :
1377
1378                        # adjusted probability
1379                        transition_list[ i ] = self.transitions[ i ].modified_deep_copy(
1380                            prob=self.transitions[ i ].prob + adjustment,
1381                            pq=None
1382                        )
1383
1384            self.set_transitions( transition_list )
1385
1386            if rename_states :
1387                self.set_states( [
1388                    s.modified_deep_copy( name=f"{i}" )
1389                    for s in self.states
1390                ] )
1391
1392        if was_q_weighted :
1393            self.to_q_weighted()
1394
1395    def to_q_weighted( self, denominator_limit=1000 ) :
1396
1397        """
1398        Approximates the existing transition probabilities with exact fractions, stores 
1399        the fractional probabilities as Fraction in Transition.pq, and sets the floating
1400        point probabilty to `float(pq)`. If `denominator_limit` is too small for a sane 
1401        conversion, the function recurses with `denominator_limit=denominator_limit*10`.
1402
1403        Args:
1404            denominator_limit (int): The initial input to :meth:`Fraction.limit_denominator` in 
1405            the conversion.
1406        """
1407
1408        if self._has_valid_rational_probabilities :
1409            return
1410
1411        if not self.is_row_stochastic() :
1412            raise ValueError( "Cannot convert to q-weighted because not row stochastic" )
1413
1414        t_from = [[] for _ in range(len(self.states))]
1415
1416        for i, tr in enumerate( self.transitions ) :
1417            t_from[ tr.origin_state_idx ].append( i )
1418
1419        new_transitions = []
1420        for t_list in t_from :
1421            
1422            if not t_list : 
1423                continue
1424
1425            p_q_sum = Fraction(0,1)
1426            p_qs = []
1427
1428            for t_idx in t_list :
1429            
1430                p_q = Fraction( self.transitions[ t_idx ].prob ).limit_denominator( denominator_limit )
1431                p_q_sum += p_q
1432                p_qs.append( p_q )
1433
1434            if p_q_sum != Fraction(1,1) :
1435                
1436                max_pq_i = np.argmax( p_qs )
1437                max_oq = p_qs[ max_pq_i ]
1438
1439                diff = p_q_sum - Fraction(1,1)
1440
1441                # If recurse with higher resolution
1442                if diff > max_oq :
1443                    return self.to_q_weighted( denominator_limit*10 )
1444                else :
1445                    p_qs[ max_pq_i ] -= diff
1446
1447            for i, t_idx in enumerate( t_list ) :	
1448                new_transitions.append( 
1449                    self.transitions[  t_idx ].modified_deep_copy(
1450                        prob=float(p_qs[ i ]),
1451                        pq=p_qs[ i ]
1452                    )
1453                )
1454
1455        self.set_transitions( new_transitions )
1456        self._has_valid_rational_probabilities = True
1457
1458    #-------------------------------------------------------#
1459    #                    Data Generation                    #
1460    #-------------------------------------------------------#
1461
1462    def isomorphic_shift(
1463        self,
1464        input_symbol_indices: np.ndarray,
1465        input_state_indices:  np.ndarray,
1466        shift : int = 1
1467    ) -> dict[str, np.ndarray]:
1468
1469        """
1470        Generates a new sequence of symbols that are permuted with the symbols emitted by
1471        isomorphic states, if they exists.
1472
1473        :math:`\\sigma_o = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i]\\right]`<br>
1474        :math:`\\sigma_t = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i+1]\\right]`
1475 
1476        :math:`\\mathcal{I}(\\sigma_o) = \\\\{\\sigma^0_o,\\, \\sigma^1_o,\\, \\dots,\\, \\sigma^{n-1}_o \\\\}`<br>
1477        :math:`\\mathcal{I}(\\sigma_t) = \\\\{\\sigma^0_t,\\, \\sigma^1_t,\\, \\dots,\\, \\sigma^{n-1}_t \\\\}`
1478 
1479        :math:`k = \\bigl(\\mathcal{I}(\\sigma_o).\\texttt{index}(\\sigma_o) + \\texttt{shift}\\bigr) \\bmod n`
1480 
1481        :math:`\\texttt{output\\_symbol\\_indices}[i]   := T(\\sigma_o^k,\\, \\sigma_t^k).\\text{symbol\\_index}`<br>
1482        :math:`\\texttt{output\\_state\\_indices}[i]    := \\mathcal{S}.\\texttt{index}(\\sigma_o^k)`<br>
1483        :math:`\\texttt{output\\_state\\_indices}[i+1]  := \\mathcal{S}.\\texttt{index}(\\sigma_t^k)`
1484
1485        Where :math:`\\mathcal{I}(\\sigma)`` is the ordered set of states isomorphic to :math:`\\sigma` including :math:`\\sigma` itself.
1486
1487        Args:
1488            input_symbol_indices (np.ndarray): The sequence of generated symbols.
1489            input_state_indices (np.ndarray): The sequence of states that generated symbols with the final state at the end.
1490            shift : int: How much to shift the symbols across the isomorphic states.
1491        """
1492        
1493        if not any( state.isomorphs for state in self.states ):
1494            raise ValueError("HMM has no states with isomorphs")
1495
1496        inputs = np.asarray(input_symbol_indices)
1497        states = np.asarray(input_state_indices)
1498
1499        n_states = len(self.states)
1500
1501        tr_sym_table    = np.full((n_states, n_states), -1, dtype=np.int32)
1502        is_pseudo_table = np.zeros((n_states, n_states), dtype=bool)
1503        tr_cross_table  = np.zeros((n_states, n_states), dtype=bool)
1504
1505        for tr in self.transitions:
1506            tr_sym_table[ tr.origin_state_idx, tr.target_state_idx ] = tr.symbol_idx
1507            tr_cross_table[tr.origin_state_idx, tr.target_state_idx] = tr.composition_depth > 0
1508
1509        for i, state in enumerate(self.states):
1510            for p_iso in state.pseudo_isomorphs:
1511                j = self.state_idx_map[p_iso]
1512                is_pseudo_table[i, j] = True
1513
1514        # Build isomorph remapping: identity by default, overridden where isomorphs exist
1515        iso_table = np.arange( n_states, dtype=np.int32 )
1516
1517        for i, state in enumerate(self.states):
1518            effective_isos = state.isomorphs | state.pseudo_isomorphs
1519            if len(effective_isos) > 0:
1520                isormorphs_with_identity = sorted([i] + [self.state_idx_map[iso] for iso in effective_isos])
1521                pos = isormorphs_with_identity.index(i)
1522                iso_table[i] = isormorphs_with_identity[(pos + shift) % len(isormorphs_with_identity)]
1523
1524        origins = states[:-1]
1525        targets = states[1:]
1526
1527        out_origins = iso_table[origins]
1528        out_targets = iso_table[targets]
1529
1530        rotated_symbols = tr_sym_table[ out_origins, out_targets ]
1531
1532        ###########################################################################
1533        # handle attempts to rotate symbols emitted by pseudo-isomorphic states
1534        
1535        # are the isomorphs used for the origin and target shift pseudo-isomorphism?
1536        origin_is_pseudo = is_pseudo_table[origins, out_origins]
1537        target_is_pseudo = is_pseudo_table[targets, out_targets]
1538
1539        # does the edge cross a component
1540        is_cross = tr_cross_table[ origins, targets ]
1541
1542        # is_cross is sufficient condition for single-level compositions, 
1543        # but condition the combined is required for multi-level composition
1544        invalid_shift = is_cross & origin_is_pseudo & target_is_pseudo
1545
1546        final_symbols = np.where( invalid_shift, inputs, rotated_symbols)
1547
1548        if np.any(final_symbols == -1):
1549            raise RuntimeError(
1550                "Invalid isomorphic shift: topology invariant violated."
1551            )
1552
1553        ############################################################################
1554
1555        invalid_state_shift = np.append( invalid_shift, invalid_shift[-1] )
1556
1557        rotated_states = np.empty(states.size, dtype=states.dtype)
1558        rotated_states[:-1] = out_origins
1559        rotated_states[-1]  = out_targets[-1]
1560
1561        rotated_states = np.where( invalid_state_shift, states, rotated_states )
1562
1563        return {
1564            "symbol_index": final_symbols.astype(inputs.dtype),
1565            "state_index":  rotated_states,
1566        }
1567
1568    def generate_belief_trajectory_from( 
1569        self, 
1570        symbols : np.ndarray ) :
1571
1572        """
1573        Tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1574
1575        Returns:
1576            np.ndarray: The belief state sequence. 
1577        """
1578
1579        T_x = self.get_T_X()
1580        pi  = self.get_stationary_distribution()
1581
1582        mu        = pi.copy()
1583        states    = np.zeros(( len(symbols) + 1, len(mu)))
1584        states[0] = mu
1585        
1586        for i, x in enumerate( symbols ) :
1587            mu = mu @ T_x[x]
1588            mu = mu / mu.sum()
1589            states[i+1] = mu
1590        return states
1591
1592    def generate_belief_trajectory( self, 
1593        n_steps : int, 
1594        random_seed : int=42 ) -> np.ndarray:
1595        """
1596        Generates `n_steps` symbols from the HMM, then tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1597
1598        Returns:
1599            np.ndarray: The belief state sequence. 
1600        """
1601
1602        trs = self.get_transition_list()
1603
1604        data = am_fast.generate_data(
1605            n_gen=n_steps,
1606            start_state=self.start_state,
1607            transitions=trs,
1608            alphabet=sorted(list(self.alphabet)),
1609            include_states=False,
1610            random_seed=random_seed
1611        )
1612
1613        return self.generate_belief_trajectory_from( data["symbol_index"] )
1614        
1615    def generate_data(
1616        self,
1617        file_prefix: str,
1618        n_gen: int,
1619        include_states: bool,
1620        row_size : int | None = None,
1621        include_belief_states : bool=False,
1622        isomorphic_shifts : set[int] | None = None,
1623        with_component_map : bool = True, 
1624        random_seed : int=42 ) -> dict[str,Any] : 
1625
1626        if isomorphic_shifts is not None and not include_states :
1627            raise ValueError( "Isomorphic inversion requires include_states=True" )
1628
1629        trs = self.get_transition_list()
1630
1631        data = am_fast.generate_data(
1632            n_gen=n_gen,
1633            start_state=self.start_state,
1634            transitions=trs,
1635            alphabet=sorted(list(self.alphabet)),
1636            include_states=include_states,
1637            random_seed=random_seed
1638        )
1639
1640        if isomorphic_shifts is not None :
1641
1642            data[ "isomorphic_shifts" ] = {}
1643
1644            for shift in isomorphic_shifts :
1645
1646                try : 
1647
1648                    shifted = self.isomorphic_shift(
1649                        input_symbol_indices=data[ "symbol_index" ], 
1650                        input_state_indices=data[ "state_index" ], 
1651                        shift=shift
1652                    )
1653
1654                    data[ "isomorphic_shifts" ][ f"{shift}" ] = {
1655                        "symbol_index" : shifted[ "symbol_index" ],
1656                        "state_index"  : shifted[ "state_index" ]
1657                    }
1658
1659                except Exception as e :
1660                    print( f"Exception {e}" )
1661
1662        if include_belief_states :
1663            belief_states = self.generate_belief_trajectory_from( data[ "symbol_index" ] )
1664            data[ "belief_states" ] = belief_states
1665
1666        metadata = self.get_metadata()
1667
1668        # Maps global state index to the component instance id the state belongs to at 
1669        # a given depth in the composition. 
1670        # somewhat hackish, namely the string based encoding, parsing, and fact that 
1671        # class cmi is only added in compositionally constructed machines
1672        if with_component_map :
1673            
1674            cmp_map = defaultdict(dict)
1675            all_cmis : set[tuple[str,str]] = set()
1676            
1677            for state_index, state in enumerate( self.states ) : 
1678                for level, l_classes in state.classes.items() :
1679                    
1680                    cmi = None
1681
1682                    level_str = str(level)
1683                    state_idx_str = str(state_index)
1684
1685                    for cls in l_classes : 
1686                        cls_tpy = cls.split( "_" )[ 0 ] 
1687                        if cls_tpy == "cmi" :
1688                            cmi = cls
1689                            break
1690                    
1691                    if cmi is not None : 
1692                        cmp_map[ state_idx_str ][ level_str ] = cmi
1693
1694                    if state_idx_str in cmp_map :
1695                        all_cmis.add( ( level_str, cmp_map[ state_idx_str ][ level_str ] ) )
1696            
1697            cmi_indices : dict[ str, dict[str, int ] ] = defaultdict(dict)
1698            for idx, cmi in enumerate( sorted( all_cmis ) ) :
1699                cmi_indices[ cmi[0] ][ cmi[1] ] = idx
1700
1701            metadata["state_component_map" ] = cmp_map
1702            metadata["all_components"      ] = sorted(all_cmis)
1703            metadata["component_indices"   ] = cmi_indices
1704
1705        if "state_index" in data :
1706            index_histogram(
1707                data=data["state_index"], 
1708                output_path=file_prefix + "_state_frequency",
1709                title="State Frequency",
1710                x_label="state index",
1711                show=False
1712            )
1713
1714        am_fast.save_data(
1715            data=data,
1716            file_prefix=file_prefix,
1717            alphabet=sorted(list(self.alphabet)),
1718            n_states=len( self.states ),
1719            start_state=self.start_state,
1720            random_seed=random_seed,
1721            row_size=row_size,
1722            machine_metadata=metadata )
1723
1724        return data
1725
1726    #-------------------------------------------------------#
1727    #                   Digraphs and DFAs                   #
1728    #-------------------------------------------------------#
1729
1730    def as_digraph( self ) -> nx.DiGraph :
1731
1732        """
1733        Builds a [networkx.DiGraph](https://networkx.org/documentation/stable/reference/classes/digraph.html) constructed from the machine's transitions with no edge symbols or weights.
1734
1735        Returns:
1736        
1737            networkx.DiGraph : the resulting graph.
1738        """
1739
1740        G = nx.DiGraph()
1741        G.add_nodes_from( [ i for i, s in enumerate( self.states ) ] )
1742
1743        for tr in self.transitions :
1744            G.add_edge( tr.origin_state_idx, tr.target_state_idx )
1745
1746        return G
1747
1748    def as_dfa( self, with_probs : bool ) :
1749
1750        """
1751        Builds an [automata.fa.dfa.DFA](https://caleb531.github.io/automata/api/fa/class-dfa/) constructed from the machine's transitions.
1752
1753        Args:
1754            with_probs (bool): If true the DFA transitions are labeled based on
1755                the symbol of the machines transition concatenated with its
1756                probability, othwise, the only the symbols.
1757
1758        Returns:
1759        
1760            automata.fa.dfa.DFA : the resulting DFA.
1761        """
1762
1763        precision=8
1764
1765        def edge_label( symb, prob ) :
1766            return f"({symb},{round(prob, precision)})"
1767
1768        # Build states, symbols, and transitions 
1769        dfa_states  = { i for i, _ in enumerate( self.states ) }
1770            
1771        if not with_probs :
1772            dfa_symbols = set( { str(t.symbol_idx) for t in self.transitions } )
1773        else : 
1774            dfa_symbols = set( { edge_label( t.symbol_idx, t.prob ) for t in self.transitions } )
1775
1776        dfa_transitions = defaultdict(dict)
1777
1778        if not with_probs :
1779            for t in self.transitions :
1780                dfa_transitions[ t.origin_state_idx ][ t.symbol_idx ] = t.target_state_idx
1781        else :
1782            for t in self.transitions :
1783                dfa_transitions[ t.origin_state_idx ][ edge_label( t.symbol_idx, t.prob ) ] = t.target_state_idx
1784
1785        # Construct the DFA
1786        return DFA(
1787            states=dfa_states,
1788            input_symbols=dfa_symbols,
1789            transitions=dfa_transitions,
1790            initial_state=self.start_state,
1791            allow_partial=True,
1792            final_states={ 
1793                s for s in dfa_states
1794            }
1795        )

Hidden Markov model implementing epsilon machines, mixed state presentations, complexity measures, and data generation.

Arguments:
  • states (list[CausalState] | None): A list of causal states.
  • transitions (list[Transition] | None): A list of transitions between states.
  • start_state (int): Index of the start state.
  • alphabet (list[str]): List of symbols making up the alphabet.
  • name (str): Name of the model.
  • description (str): Description of the model.
HMM( states: Sequence[amachine.am_causal_state.CausalState], transitions: Sequence[amachine.am_transition.Transition], alphabet: Sequence[str], start_state: int = 0, composition_depth: int = 0, name: str = '', description: str = '')
48    def __init__( 
49        self,
50        states      : Sequence[CausalState],
51        transitions : Sequence[Transition],
52        alphabet    : Sequence[str],
53        start_state : int = 0,
54        composition_depth : int = 0,
55        name        : str = "",
56        description : str = "" ) : 
57
58        self.unique_id = next(HMM._id_generator)
59
60        # MappingProxyType offers immutable view of dict
61        self.symbol_idx_map : MappingProxyType = MappingProxyType( {} )
62        self.state_idx_map  : MappingProxyType = MappingProxyType( {} )
63
64        self.alphabet    = tuple( alphabet    )   
65        self.states      = tuple( states      )
66        self.transitions = tuple( transitions )
67
68        self.update_symbol_idx_map()
69        self.update_state_idx_map()
70
71        self.name : str = name
72        self.description : str = description
73
74        self.composition_depth = composition_depth
75
76        self.start_state : int = start_state
77
78        # --- derived --------
79
80        self._complexity : dict[str, Any] = {}
81        self._pi_fractional = None
82        self._pi : np.ndarray | None = None
83        
84        self._T : np.ndarray | None = None
85        self._T_x  : list[np.ndarray] | None = None
86        self._msp : MSP | None = None
87
88        self._reverse_am : HMM | None = None
89        self._has_valid_rational_probabilities : bool = False
90        self._already_minimized : bool = False
91
92        # --- const ----------
93
94        self._EPS : float = 1e-12
unique_id
symbol_idx_map: mappingproxy
state_idx_map: mappingproxy
alphabet
states
transitions
name: str
description: str
composition_depth
start_state: int
def clear_cache(self):
100    def clear_cache(self) :
101
102        """
103        Reset all derived properties so they will be recomputed when requested later.
104        """
105
106        self._complexity = {}
107        self._T = None
108        self._T_x = None
109        self._pi = None
110        self._pi_fractional = None
111        self._msp = None 
112        self._reverse_am = None
113
114        self._has_valid_rational_probabilities = False
115        self._already_minimized = False

Reset all derived properties so they will be recomputed when requested later.

def update_symbol_idx_map(self):
117    def update_symbol_idx_map(self) :
118        new_idx_map = {}
119        for idx, symbol in enumerate( self.alphabet ) :
120            new_idx_map[ symbol ] = idx
121        self.symbol_idx_map = MappingProxyType( new_idx_map )
def update_state_idx_map(self):
123    def update_state_idx_map(self) :
124        new_idx_map = {}
125        for idx, state in enumerate( self.states ) :
126            new_idx_map[ state.name ] = idx
127        self.state_idx_map = MappingProxyType( new_idx_map )
def set_states(self, states: Sequence[amachine.am_causal_state.CausalState]):
129    def set_states( self, states : Sequence[CausalState] ) :
130        self.clear_cache()        
131        self.states = tuple( states )
132        self.update_state_idx_map()
def set_alphabet(self, alphabet: Sequence[str]):
134    def set_alphabet( self, alphabet : Sequence[str] ) :
135
136        self.clear_cache()
137
138        old_alphabet = tuple( self.alphabet )
139
140        self.alphabet = tuple( sorted( set( alphabet ) ) )
141        self.update_symbol_idx_map()
142
143        new_transitions = []
144        for i, tr in enumerate( self.transitions ) :
145            symbol = old_alphabet[ tr.symbol_idx ]
146            new_transitions.append(  
147                tr.modified_deep_copy(
148                    symbol_idx=self.symbol_idx_map[ symbol ]
149                )
150            )
151
152        self.set_transitions( new_transitions )
def set_transitions(self, transitions: Sequence[amachine.am_transition.Transition]):
154    def set_transitions( self, transitions : Sequence[Transition] ) :
155        self.clear_cache()
156        self.transitions = tuple( transitions )
def get_complexity_measure_if_exists(self, measure):
158    def get_complexity_measure_if_exists(self, measure ) :
159        m = self._complexity.get( measure, None )
160        return m
def set_complexity_measure(self, measure, value):
162    def set_complexity_measure(self, measure, value ) :
163        self._complexity[ measure ] = value
is_q_weighted
184    @property
185    def is_q_weighted(self) :
186        return self._has_valid_rational_probabilities
def from_json_dict(self, config: dict[str, typing.Any]):
192    def from_json_dict( self, config : dict[str, Any] ) :
193
194        self.name             = config.get( "name", "" )
195        self.description      = config.get( "description", "" )
196        self.start_state     = config.get( "start_state", 0 )
197        
198        json_states      = config.get( "states",      [] )
199        json_transitions = config.get( "transitions", [] )
200
201        states=[ 
202            CausalState.from_json_dict( state )
203            for state in json_states
204        ]
205
206        transitions=[ 
207            Transition.from_json_dict( tr )
208            for tr in json_transitions
209        ]
210
211        self.alphabet = config.get( "alphabet", () )
212
213        self.states      = tuple( states )
214        self.transitions = tuple( transitions )
215
216        self.update_symbol_idx_map()
217        self.update_state_idx_map()
def to_json_dict(self) -> dict[str, typing.Any]:
219    def to_json_dict(self) -> dict[ str, Any ]:
220
221        """Create a dict representing the HMM configuration.
222
223        Returns:
224
225            dict[str,any]: Dictionary containing, name, description, states, transitions, alphabet.
226        """
227
228        return {
229            "name"            : self.name,
230            "description"     : self.description,
231            "start_state"     : self.start_state,
232            "states"          : [ state.to_json_dict()      for state      in self.states      ],
233            "transitions"     : [ transition.to_json_dict() for transition in self.transitions ],
234            "alphabet"        : list(self.alphabet)
235        }

Create a dict representing the HMM configuration.

Returns:

dict[str,any]: Dictionary containing, name, description, states, transitions, alphabet.

def save_config( self, output_dir: pathlib.Path | str, with_complexity: bool = False, with_non_trivial_complexity: bool = False):
237    def save_config(
238        self, 
239        output_dir : Path | str, 
240        with_complexity : bool = False, 
241        with_non_trivial_complexity : bool = False ) :
242
243        output_dir = Path( output_dir )
244
245        config = self.to_json_dict()
246
247        if with_complexity :
248            
249            complexity = self.get_complexities( 
250                with_non_trivial=with_non_trivial_complexity
251            )
252
253            config[ "complexity" ] = complexity
254
255        config[ "structural_properties" ] = {
256            "unifilar"              : self.is_unifilar(),
257            "row_stochastic"        : self.is_row_stochastic(),
258            "strongly_connected"    : self.is_strongly_connected(),
259            "aperiodic"             : self.is_aperiodic() #,
260            # "minimal"               : self._is_minimal_as_dfa( topological_only=False )
261        }
262
263        with open( output_dir / "am_config.json", "w", encoding="utf-8" ) as f :
264            json.dump( config, f, ensure_ascii=False, indent=2, default=list )
def from_file(self, path: pathlib.Path):
266    def from_file( self, path : Path ) :
267        with open( path / "am_config.json", "r" ) as f:
268            config = json.load(f)
269        self.from_json_dict( config )
def get_transition_list(self) -> list[list[tuple[int, float, int]]]:
275    def get_transition_list(self) -> list[list[tuple[int, float, int]]] :
276        trs = [ [] for _ in range( len( self.states ) ) ]
277        for tr in self.transitions :
278            trs[ tr.origin_state_idx ].append( ( 
279                tr.symbol_idx, 
280                float( tr.prob ),
281                tr.target_state_idx ) )
282        return trs
def get_complexities(self, with_non_trivial=False):
284    def get_complexities( 
285        self, 
286        with_non_trivial=False ) :
287
288        trivial = [
289            self.C_mu,
290            self.h_mu,
291            self.H_1,
292            self.rho_mu
293        ]
294
295        non_trivial = [
296            self.E, 
297            self.T_inf,
298            self.S,
299            self.chi
300        ]
301            
302        complexities = { m.__name__ : m() for m in trivial }
303
304        if with_non_trivial :
305
306            complexities |= { m.__name__ : float( m() ) for m in non_trivial }
307
308            _ = self.block_convergence()
309
310            scalar_complexity_keys = {
311                "E",    
312                "S",    
313                "T_inf"
314            }
315
316            complexities[ "block" ] = {}
317            for key in scalar_complexity_keys :
318                c = self.get_complexity_measure_if_exists( key )
319                if c is not None :
320                    complexities[ "block" ][ key ] = float( c )
321
322            block_complexity_keys = {
323                "E_L",   
324                "T_L",   
325                "S_L",   
326                "H_L",   
327                "h_mu_L",
328                "H_sync"
329            }
330
331            for key in block_complexity_keys :
332                bc = self.get_complexity_measure_if_exists( key )
333                if bc is not None :
334                    complexities[ "block" ][ key ] = [ float(x) for x in bc ]
335
336        return complexities
def get_metadata(self):
340    def get_metadata(self) :
341
342        C = self.get_complexities( with_non_trivial=False )
343
344        return {
345            "name" : self.name,
346            'complexity'  : self._complexity,
347            "description" : self.description
348        }
def get_transition_matrix(self):
350    def get_transition_matrix(self) :
351
352        if self._T  is not None :
353            return self._T
354
355        n_states = len( self.states )
356        T = np.zeros((n_states, n_states))
357
358        for tr in self.transitions :    
359            T[ tr.origin_state_idx, tr.target_state_idx  ] = tr.prob
360
361        self._T = T
362
363        return self._T
def get_T_X(self):
367    def get_T_X(self) :
368
369        if self._T_x  is not None :
370            return self._T_x
371
372        n_states  = len( self.states )
373        n_symbols = len( self.alphabet )
374
375        T_x = [ np.zeros( ( n_states, n_states) ) for _ in range( n_symbols ) ]
376
377        for tr in self.transitions :
378            T_x[ tr.symbol_idx ][tr.origin_state_idx, tr.target_state_idx] = tr.prob
379
380        self._T_x = T_x
381        return self._T_x
def get_msp_qw( self, exact_state_cap: int = 1000, verbose: bool = True) -> amachine.am_msp.MSP:
385    def get_msp_qw(
386        self,
387        exact_state_cap: int = 1000,
388        verbose: bool = True,
389    ) -> MSP :
390        if self._msp is not None:
391            return self._msp
392
393        try : 
394
395            print( "\nTrying to Compute Mixed State Presentation using Exact Fractions\n" )
396
397            self._msp = compute_msp_exact(
398                T_x=self.get_Tx_fractional(),
399                pi=self.get_fractional_stationary_distribution(),
400                n_states=len(self.states),
401                alphabet=self.alphabet,
402                exact_state_cap=exact_state_cap,
403                verbose=verbose
404            )
405
406            return self._msp 
407
408        except RuntimeError as e :
409            warnings.warn( f"Exact msp failed: {e} Falling back to msp approximation." )
410
411        return self.get_msp()
def get_msp( self, exact_state_cap: int = 1250000, verbose=True) -> amachine.am_msp.MSP:
413    def get_msp(
414        self,
415        exact_state_cap: int = 1_250_000,
416        verbose = True,
417    ) -> MSP :
418
419        if self._msp is not None:
420            return self._msp
421     
422        T_x = self.get_T_X()
423        pi  = self.get_stationary_distribution()
424    
425        print( "\nComputing Mixed State Presentation..." )
426
427        self._msp = compute_msp( 
428            T_x=T_x,
429            pi=pi,
430            n_states=len(self.states),
431            alphabet=self.alphabet,
432            exact_state_cap=exact_state_cap,
433            verbose=verbose
434        )
435
436        return self._msp
def get_reverse_am(self):
438    def get_reverse_am(self) :
439
440        was_q_weighted = self._has_valid_rational_probabilities
441
442        if self._reverse_am is not None:
443            return self._reverse_am
444
445        pi = self.get_stationary_distribution()
446        self._reverse_am = copy.deepcopy(self)
447        
448        new_transitions = []
449        for tr in self.transitions:
450            i = tr.target_state_idx
451            j = tr.origin_state_idx
452            
453            p_reversed = (pi[j] * tr.prob) / pi[i]
454            
455            new_transitions.append(
456                tr.modified_deep_copy(
457                    origin_state_idx=i,
458                    target_state_idx=j,
459                    prob=p_reversed,
460                    pq=None
461                )
462            )
463
464        self._reverse_am.set_transitions(new_transitions)
465
466        if self._reverse_am.is_epsilon_machine():
467            return self._reverse_am
468
469        rmsp = self._reverse_am.get_msp_qw( exact_state_cap=len(self.states)*4 )
470
471        self._reverse_am.set_states( rmsp.states )
472        self._reverse_am.set_transitions( rmsp.transitions )
473        self._reverse_am._msp = rmsp
474        self._reverse_am.start_state = 0
475
476        self._reverse_am.collapse_to_largest_strongly_connected_subgraph()
477        self._reverse_am.minimize()
478
479        if was_q_weighted :
480            self._reverse_am.to_q_weighted()
481
482        return self._reverse_am
def get_Tx_fractional(self) -> list[list[list[fractions.Fraction]]]:
486    def get_Tx_fractional(self) -> list[ list[ list[ Fraction ] ] ] :
487
488        self.to_q_weighted()
489
490        n_states  = len( self.states )
491        n_symbols = len( self.alphabet )
492
493        T_x = []
494
495        for x in range( n_symbols ) :
496            T_x.append( [] )
497            for i in range( n_states ) :
498                T_x[ x ].append( [ 0 for _ in range( n_states ) ] )
499
500        for tr in self.transitions :
501            T_x[ tr.symbol_idx ][ tr.origin_state_idx ][ tr.target_state_idx ] = tr.pq
502
503        return T_x
def get_T_sympy(self):
505    def get_T_sympy( self ) :
506
507        self.to_q_weighted()
508
509        n = len( self.states )
510        T = sympy.zeros( n, n )
511
512        for tr in self.transitions :
513            T[ tr.origin_state_idx, tr.target_state_idx ] = tr.pq
514
515        return T
def get_fractional_stationary_distribution(self):
517    def get_fractional_stationary_distribution(self) :
518
519        T = self.get_T_sympy()
520
521        if self._pi_fractional is not None :
522            return self._pi_fractional
523
524        G = self.as_digraph()
525
526        if not nx.is_strongly_connected(G):
527            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
528
529        self._pi_fractional = solve_for_pi_fractional( T )
530
531        return self._pi_fractional
def get_stationary_distribution(self):
533    def get_stationary_distribution(self):
534
535        if self._pi is not None :
536            return self._pi
537
538        G = self.as_digraph()
539        
540        if not nx.is_strongly_connected(G):
541            raise ValueError( "Single stationary distribution requires strongly connected HMM." )
542
543        T = self.get_transition_matrix()
544        return solve_for_pi( T )		
def C_mu(self):
550    def C_mu( self ) :
551
552        """The *statistical complexity* (aka *forecasting complexity*) :
553
554        .. math::
555
556            C_{\\mu} = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\log_2 \\Pr(\\sigma),
557
558        where :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
559
560        .. note::
561
562            **Interpretations**
563
564            * The amount of historical information a process stores.
565            * The amount of structure in a process.
566
567        Returns:
568
569            float: :math:`C_{\\mu}`.
570
571        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
572            Decomposition of Intrinsic Computation*, 2016.
573            <https://arxiv.org/abs/1309.3792>
574        """
575
576        m = self.get_complexity_measure_if_exists( "C_mu" )
577
578        if m is not None :
579            return m
580
581        pi = self.get_stationary_distribution()
582
583        h = 0
584        for i, pr in enumerate( pi ) :
585            
586            if pr < self._EPS :
587                continue
588
589            h += -pr * np.log2( pr )
590
591        self.set_complexity_measure( "C_mu", h )
592
593        return h

The statistical complexity (aka forecasting complexity) :

$$C_{\mu} = - \sum_{\sigma \in \mathcal{S}} \Pr(\sigma) \log_2 \Pr(\sigma),$$

where \( \mathcal{S} \) is the set of states 1, p.2.

Interpretations

  • The amount of historical information a process stores.
  • The amount of structure in a process.
Returns:

float: \( C_{\mu} \).


  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def h_mu(self):
597    def h_mu( self ) :
598
599        """The *entropy rate* :
600
601        .. math::
602
603            h_{\\mu}(\\boldsymbol{\\mathcal{S}}) = - \\sum_{\\sigma \\in \\mathcal{S}} \\Pr(\\sigma) \\sum_{x \\in \\mathcal{A}} \\Pr(x|\\sigma) \\log_2 \\Pr(x|\\sigma),
604
605        where :math:`\\mathcal{A}` is the alphabet and :math:`\\mathcal{S}` is the set of states [^crutchfield_exact_2016], p.2.
606
607        .. note::
608
609            **Interpretations**
610
611            * The lower bound on achievable loss in bits. 
612            * The irreducable randomness in the process.
613            * The intrinsic Randomness in the process.
614
615        Returns:
616            
617            float: :math:`h_{\\mu}`.
618
619        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
620            Decomposition of Intrinsic Computation*, 2016.
621            <https://arxiv.org/abs/1309.3792>
622        """
623
624        m = self.get_complexity_measure_if_exists( "h_mu" )
625
626        if m is not None :
627            return m
628
629        T  = self.get_transition_matrix()
630        pi = self.get_stationary_distribution()
631
632        n_states = pi.size
633
634        h = 0
635        for i, pr in enumerate( pi ) :
636
637            if pr < self._EPS :
638                continue
639
640            row_entropy = 0
641            for j in range( len( pi ) ) :
642
643                if T[ i, j ]  < self._EPS :
644                    continue
645
646                row_entropy -= T[ i, j ] * np.log2( T[ i, j ] )
647
648            h += pr * row_entropy
649
650        self.set_complexity_measure( "h_mu", h )
651
652        return h

The entropy rate :

$$h_{\mu}(\boldsymbol{\mathcal{S}}) = - \sum_{\sigma \in \mathcal{S}} \Pr(\sigma) \sum_{x \in \mathcal{A}} \Pr(x|\sigma) \log_2 \Pr(x|\sigma),$$

where \( \mathcal{A} \) is the alphabet and \( \mathcal{S} \) is the set of states 1, p.2.

Interpretations

  • The lower bound on achievable loss in bits.
  • The irreducable randomness in the process.
  • The intrinsic Randomness in the process.
Returns:

float: \( h_{\mu} \).


  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def H_1(self) -> float:
656    def H_1(self) -> float :
657
658        """The *single symbol uncertainty*:
659
660        .. math::
661
662            H(1)=-\\sum_{x\\in\\mathcal{A}} \\Pr(x) \\log_2{\\Pr(x)},
663
664        where :math:`\\mathcal{A}` is the alphabet [^James_2018], p.2.
665
666        .. note::
667
668            **Interpretations**
669
670            * How uncertain you are on average about a single measurement with no context.
671
672        Returns:
673
674            float: :math:`H(1)`.
675
676        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
677            <https://arxiv.org/abs/1105.2988>
678        """
679
680        m = self.get_complexity_measure_if_exists("H_1")
681        if m is not None:
682            return m
683
684        pi  = self.get_stationary_distribution()
685        T_X = self.get_T_X()  # dict: symbol -> matrix
686
687        h = 0.0
688        for T_x in T_X:
689            # Pr(x) = sum_i pi[i] * sum_j T^(x)[i,j]
690            p_sym = 0.0
691            for i, pr in enumerate(pi):
692                if pr < self._EPS:
693                    continue
694                p_sym += pr * T_x[i, :].sum()
695
696            if p_sym < self._EPS:
697                continue
698            h -= p_sym * np.log2(p_sym)
699
700        self.set_complexity_measure("H_1", h)
701        return h

The single symbol uncertainty:

$$H(1)=-\sum_{x\in\mathcal{A}} \Pr(x) \log_2{\Pr(x)},$$

where \( \mathcal{A} \) is the alphabet 1, p.2.

Interpretations

  • How uncertain you are on average about a single measurement with no context.
Returns:

float: \( H(1) \).


  1. James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018. https://arxiv.org/abs/1105.2988 

def rho_mu(self) -> float:
705    def rho_mu(self) -> float :
706        
707        """The *anticipated information* [^James_2018], p.3.:
708
709        .. math::
710
711            \\rho_{\\mu}= H(1) - h_{\\mu}
712
713        Returns:
714            
715            float: :math:`\\rho_{\\mu}`
716
717        [^James_2018]: James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018.
718            <https://arxiv.org/abs/1105.2988>
719        """
720
721        m = self.get_complexity_measure_if_exists("rho_mu")
722        
723        if m is not None:
724            return m
725
726        rho = self.H_1() - self.h_mu()
727        
728        self.set_complexity_measure("rho_mu", rho)
729        
730        return rho

The anticipated information 1, p.3.:

$$\rho_{\mu}= H(1) - h_{\mu}$$

Returns:

float: \( \rho_{\mu} \)


  1. James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018. https://arxiv.org/abs/1105.2988 

def block_convergence(self):
734    def block_convergence( self )  :
735
736        """
737        Run [block entropy convergence](am_fast.html#block_entropy_convergence). Estimates [$\\mathbf{E}$](am_hmm.html#HMM.E), [$\\mathbf{S}$](am_hmm.html#HMM.S), [$\\mathbf{T}$](am_hmm.html#HMM.T_inf), and block measures[^crutchfield_exact_2016]:
738
739        - $\\mathbf{E}(L) = H(L) - L \\cdot h_{\\mu}$, 
740
741        - $\\mathbf{T}(L) = \\sum_{l=1}^{L} l \\left[ h_{\\mu}(l) - h_{\\mu} \\right]$, 
742
743        - $\\mathbf{S}(L) = \\sum_{l=0}^{L} \\mathcal{H}(l)$, 
744
745        - $H(L) = H[X_{0:L}]$, 
746
747        - $h_{\\mu}(L) = H(L) - H(L-1)$, and 
748
749        - $\\mathcal{H}(L) = -\\sum_{w \\in \\mathcal{A}^L} Pr(w) \\sum_{\\sigma \\in \\mathcal{S}} Pr(\\sigma|w) \\log_2 Pr(\\sigma|w)$.
750
751        You can plot these curves, and the block entropy curves using, [`amachine.HMM.draw_block_measure_curves`](am_hmm.html#HMM.draw_block_measure_curves), and [`amachine.HMM.draw_block_entropy_curve`](am_hmm.html#HMM.draw_block_entropy_curve). 
752
753        <img src="../resources/curves.png" alt="block measures plots" style="width: 100%; margin-left: 0%;">
754
755        Returns:
756        
757            ComplexityMeasures: An object containing the estimated measures with the following attributes:
758            
759            - E (float): The excess entropy.
760            - T_inf (float): The transient information ($\\mathbf{T}$).
761            - S (float): The synchronization information.
762            - E_L (numpy.ndarray): The block excess entropy ($\\mathbf{E}(L)$).
763            - T_L (numpy.ndarray): The block transient information ($\\mathbf{T}(L)$).
764            - S_L (numpy.ndarray): The block synchronization information ($\\mathbf{S}(L)$).
765            - H_L (numpy.ndarray): The block entropy ($H(L)$).
766            - h_mu_L (numpy.ndarray): The entropy rate estimates ($h_{\\mu}(L)$).
767            - H_sync (numpy.ndarray): The state-block synchronization ($\\mathcal{H}(L)$).
768            - converged (bool): True if the algorithm converged.
769
770        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
771            Decomposition of Intrinsic Computation*, 2016.
772            <https://arxiv.org/abs/1309.3792>
773        """
774
775        trs = [ [] for _ in range( len( self.states ) ) ]
776        for tr in self.transitions :
777            trs[ tr.origin_state_idx ].append( ( 
778                tr.symbol_idx, 
779                float( tr.prob ),
780                tr.target_state_idx ) )
781
782        pi = self.get_stationary_distribution()
783
784        state_dist = [ float( pi[ i ] ) for i in range( len( self.states ) ) ]
785        branches = [(1.0, list(state_dist))]
786
787        print( "\nComputing Block Entropy\n" )
788
789        C = am_fast.block_entropy_convergence(
790            h_mu            = self.h_mu(),
791            n_states        = len( self.states ),
792            n_symbols       = len( self.alphabet ),
793            convergence_tol = 1e-8,
794            precision       = 15,
795            eps             = 1e-25,
796            branches        = branches,
797            trans           = trs,
798            max_branches    = 30_000_000
799        )
800
801        print( "Done\n" )
802
803        self.set_complexity_measure( f"E",       C.E )
804        self.set_complexity_measure( f"S",       C.S )
805        self.set_complexity_measure( f"T_inf",   C.T )
806        self.set_complexity_measure( f"E_L",     C.E_L.tolist() )
807        self.set_complexity_measure( f"T_L",     C.T_L.tolist() )
808        self.set_complexity_measure( f"S_L",     C.S_L.tolist() )
809        self.set_complexity_measure( f"H_L",     C.H_L.tolist() )
810        self.set_complexity_measure( f"h_mu_L",  C.h_mu_L.tolist() )
811        self.set_complexity_measure( f"H_sync",  C.H_sync.tolist() )
812
813        return C

Run block entropy convergence. Estimates $\mathbf{E}$, $\mathbf{S}$, $\mathbf{T}$, and block measures1:

  • $\mathbf{E}(L) = H(L) - L \cdot h_{\mu}$,

  • $\mathbf{T}(L) = \sum_{l=1}^{L} l \left[ h_{\mu}(l) - h_{\mu} \right]$,

  • $\mathbf{S}(L) = \sum_{l=0}^{L} \mathcal{H}(l)$,

  • $H(L) = H[X_{0:L}]$,

  • $h_{\mu}(L) = H(L) - H(L-1)$, and

  • $\mathcal{H}(L) = -\sum_{w \in \mathcal{A}^L} Pr(w) \sum_{\sigma \in \mathcal{S}} Pr(\sigma|w) \log_2 Pr(\sigma|w)$.

You can plot these curves, and the block entropy curves using, amachine.HMM.draw_block_measure_curves, and amachine.HMM.draw_block_entropy_curve.

block measures plots

Returns:

ComplexityMeasures: An object containing the estimated measures with the following attributes:

  • E (float): The excess entropy.
  • T_inf (float): The transient information ($\mathbf{T}$).
  • S (float): The synchronization information.
  • E_L (numpy.ndarray): The block excess entropy ($\mathbf{E}(L)$).
  • T_L (numpy.ndarray): The block transient information ($\mathbf{T}(L)$).
  • S_L (numpy.ndarray): The block synchronization information ($\mathbf{S}(L)$).
  • H_L (numpy.ndarray): The block entropy ($H(L)$).
  • h_mu_L (numpy.ndarray): The entropy rate estimates ($h_{\mu}(L)$).
  • H_sync (numpy.ndarray): The state-block synchronization ($\mathcal{H}(L)$).
  • converged (bool): True if the algorithm converged.

  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def E(self) -> float:
817    def E( self ) -> float :
818
819        """The *excess entropy* [^crutchfield_exact_2016], p.4:
820
821        .. math::
822
823            \\mathbf{E} \\equiv \\sum_{L=1}^{\\infty} I[X_{-\\infty:0}; X_{0:\\infty}]
824        
825        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
826
827        .. note::
828
829            **Interpretations**
830
831            * The information from the past that reduces uncertainty in the future [^crutchfield_exact_2016].
832            * How much information an observer must extract to synchronize to the process.
833            * Measures how long the process appears more complex than it asymptotically is.
834            * Vanishes for immediately synchronizable processes.
835
836        Returns:
837        
838            float: :math:`\\mathbf{E}`
839
840        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
841            Decomposition of Intrinsic Computation*, 2016.
842            <https://arxiv.org/abs/1309.3792>
843        """
844
845        m = self.get_complexity_measure_if_exists( "E" )
846
847        if m is not None :
848            return m
849
850        try : 
851            msp = self.get_msp()
852            E, S, T = msp.get_E_S_T()
853            self.set_complexity_measure( "E", E )
854            self.set_complexity_measure( "S", S )
855            self.set_complexity_measure( "T_inf", T )
856            
857        except Exception as e :
858
859            print( f"MSP failed {e}" )
860
861            C = self.block_convergence()	
862            E = C.E
863            self.set_complexity_measure( "E", E )
864
865        return E

The excess entropy 1, p.4:

$$\mathbf{E} \equiv \sum_{L=1}^{\infty} I[X_{-\infty:0}; X_{0:\infty}]$$

Computed via get_msp() and amachine.am_msp.MSP.get_E_S_T(), or amachine.am_fast.block_entropy_convergence()

Interpretations

  • The information from the past that reduces uncertainty in the future 1.
  • How much information an observer must extract to synchronize to the process.
  • Measures how long the process appears more complex than it asymptotically is.
  • Vanishes for immediately synchronizable processes.
Returns:

float: \( \mathbf{E} \)


  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def S(self) -> float:
869    def S( self ) -> float :
870
871        """The *synchronization* information:
872
873        .. math::
874
875            \\mathbf{S} \\equiv \\sum_{L=1}^{\\infty} \\mathcal{H}(L),
876
877        where :math:`\\mathcal{H}(L)` is the average state uncertainty having seen all length-L words [^crutchfield_exact_2016], p.4.
878
879        .. note::
880
881            **Interpretations**
882
883            * The total amount of state information that an observer must extract to become synchronized [^crutchfield_exact_2016].
884
885        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
886
887        Returns:
888        
889            float: :math:`\\mathbf{S}`
890
891        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
892            Decomposition of Intrinsic Computation*, 2016.
893            <https://arxiv.org/abs/1309.3792>
894        """
895
896        m = self.get_complexity_measure_if_exists( "S" )
897
898        if m is not None :
899            return m
900
901        try : 
902            msp = self.get_msp()
903            E, S, T = msp.get_E_S_T()
904            self.set_complexity_measure( "E", E )
905            self.set_complexity_measure( "S", S )
906            self.set_complexity_measure( "T_inf", T )
907
908        except Exception as e :
909            print( f"{e} \nFalling back to iterative estimation.")
910            C = self.block_convergence()	
911            S = C.S
912            self.set_complexity_measure( "S", S )
913
914        return S

The synchronization information:

$$\mathbf{S} \equiv \sum_{L=1}^{\infty} \mathcal{H}(L),$$

where \( \mathcal{H}(L) \) is the average state uncertainty having seen all length-L words 1, p.4.

Interpretations

  • The total amount of state information that an observer must extract to become synchronized 1.

Computed via get_msp() and amachine.am_msp.MSP.get_E_S_T(), or amachine.am_fast.block_entropy_convergence()

Returns:

float: \( \mathbf{S} \)


  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def T_inf(self) -> float:
918    def T_inf( self ) -> float :
919
920        """The *transient information*[^crutchfield_exact_2016], p.4:
921
922        .. math::
923
924            \\mathbf{T} \\equiv \\sum_{L=1}^{\\infty} L \\left[ h_{\\mu}(L) - h_{\\mu} \\right]
925
926        Computed via :meth:`get_msp` and :meth:`amachine.am_msp.MSP.get_E_S_T`, or :meth:`amachine.am_fast.block_entropy_convergence`
927
928        .. note::
929
930            **Interpretations**
931
932            * The amount of information one must extract from observations so that the block entropy converges to its linear asymptote[^crutchfield_exact_2016].
933
934        Returns:
935        
936            float: :math:`\\mathbf{T}`
937
938        [^crutchfield_exact_2016]: Crutchfield et al., *Exact Complexity: The Spectral
939            Decomposition of Intrinsic Computation*, 2016.
940            <https://arxiv.org/abs/1309.3792>
941        """
942
943        m = self.get_complexity_measure_if_exists( "T_inf" )
944
945        if m is not None :
946            return m
947
948        try : 
949            msp = self.get_msp()
950            E, S, T = msp.get_E_S_T()
951            self.set_complexity_measure( "E", E )
952            self.set_complexity_measure( "S", S )
953            self.set_complexity_measure( "T_inf", T )
954            T_inf = T
955
956        except Exception as e :
957            print( f"{e} \nFalling back to iterative estimation.")
958            C = self.block_convergence()	
959            T_inf = C.T
960
961        return T_inf

The transient information1, p.4:

$$\mathbf{T} \equiv \sum_{L=1}^{\infty} L \left[ h_{\mu}(L) - h_{\mu} \right]$$

Computed via get_msp() and amachine.am_msp.MSP.get_E_S_T(), or amachine.am_fast.block_entropy_convergence()

Interpretations

  • The amount of information one must extract from observations so that the block entropy converges to its linear asymptote1.
Returns:

float: \( \mathbf{T} \)


  1. Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 

def chi(self) -> float:
 965    def chi( self ) -> float :
 966
 967        """The foward crypticity[^crutchfield_crypticity_2009][^Mahoney_crypticity_2021], p.2:
 968
 969        .. math::
 970
 971            \\chi = C_{\\mu} - \\mathbf{E}
 972
 973        :math:`C_{\\mu}` is trivially computed from the stationary distribution in :meth:`C_mu` and :math:`\\mathbf{E}` in :meth:`E`.
 974
 975        .. note::
 976
 977            **Interpretations**
 978
 979            * Difference between internal stored information and apparent information to an observer.
 980            * How muching information is hiding in the system.
 981
 982        Returns:
 983        
 984            float: :math:`\\chi`
 985
 986        [^crutchfield_crypticity_2009]: Crutchfield et al., Time’s barbed arrow: Irreversibility, crypticity, and stored information, 2009.
 987            <https://arxiv.org/abs/0902.1209>
 988
 989        [^Mahoney_crypticity_2021]: Mahoney et al., Information Accessibility and Cryptic Processes, 2021.
 990            <https://arxiv.org/abs/0905.4787>
 991        """
 992
 993        m = self.get_complexity_measure_if_exists( "chi" )
 994
 995        if m is not None :
 996            return m
 997
 998        chi = self.C_mu() - self.E()
 999
1000        if chi < 0 :
1001            
1002            # if chi is 0, accumulated floating point error can result in small negative values
1003            if chi < -1e-5:
1004                warnings.warn(f"Crypticity is negative ({chi:.6e}).")
1005            
1006            chi = np.clip( chi, 0 )
1007
1008        self.set_complexity_measure( "chi", chi )
1009
1010        return chi

The foward crypticity12, p.2:

$$\chi = C_{\mu} - \mathbf{E}$$

\( C_{\mu} \) is trivially computed from the stationary distribution in C_mu() and \( \mathbf{E} \) in E().

Interpretations

  • Difference between internal stored information and apparent information to an observer.
  • How muching information is hiding in the system.
Returns:

float: \( \chi \)


  1. Crutchfield et al., Time’s barbed arrow: Irreversibility, crypticity, and stored information, 2009. https://arxiv.org/abs/0902.1209 

  2. Mahoney et al., Information Accessibility and Cryptic Processes, 2021. https://arxiv.org/abs/0905.4787 

def is_row_stochastic(self):
1016    def is_row_stochastic(self) :
1017
1018        """
1019        Check that all states have outgoing transition probabilities that sum to 1.
1020        """
1021
1022        sums = np.zeros( len( self.states ) )
1023        for tr in self.transitions :
1024            sums[ tr.origin_state_idx ] += tr.prob
1025
1026        return np.allclose( sums, 1.0 )

Check that all states have outgoing transition probabilities that sum to 1.

def is_unifilar(self):
1030    def is_unifilar(self):
1031        """
1032        Check that no state emits the same symbol on transitions to different states.
1033        """
1034
1035        seen_state_symbols = set()
1036        for tr in self.transitions:
1037
1038            sym_key = (tr.origin_state_idx, tr.symbol_idx)
1039
1040            # If we've seen this origin+symbol or origin+target before
1041            if sym_key in seen_state_symbols :
1042                return False
1043
1044            seen_state_symbols.add(sym_key)
1045
1046        return True

Check that no state emits the same symbol on transitions to different states.

def is_strongly_connected(self):
1050    def is_strongly_connected(self) :
1051
1052        """
1053        Check if every state is reachable from every other state. Relies on [nx.is_strongly_connected](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.components.is_strongly_connected.html).
1054        """
1055
1056        return nx.is_strongly_connected( self.as_digraph() )

Check if every state is reachable from every other state. Relies on nx.is_strongly_connected.

def is_aperiodic(self):
1060    def is_aperiodic(self) :
1061
1062        """
1063        Checks if machine is periodic. Relies on [nx.is_aperiodic](https://networkx.org/documentation/latest/reference/algorithms/generated/networkx.algorithms.dag.is_aperiodic.html), "A strongly connected directed graph is aperiodic if there is no integer k > 1 that divides the length of every cycle in the graph."
1064        """
1065
1066        return nx.is_aperiodic( self.as_digraph() )

Checks if machine is periodic. Relies on nx.is_aperiodic, "A strongly connected directed graph is aperiodic if there is no integer k > 1 that divides the length of every cycle in the graph."

def is_topological_epsilon_machine(self, verbose=True):
1089    def is_topological_epsilon_machine( self, verbose=True ) :
1090
1091        """
1092        Checks if the HMM is a topological $\\epsilon$-machine [^1].
1093
1094        [^1]: Johnson et al, Enumerating Finitary Processes, 2024.
1095            <https://arxiv.org/abs/1011.0036>
1096        """
1097
1098        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1099            if verbose : 
1100                print( f"Either non unifilar or not strongly connected" )
1101            return False
1102        else :
1103            return self._is_minimal_as_dfa( topological_only=True, verbose=verbose )

Checks if the HMM is a topological $\epsilon$-machine 1.


  1. Johnson et al, Enumerating Finitary Processes, 2024. https://arxiv.org/abs/1011.0036 

def is_epsilon_machine(self, verbose=True):
1105    def is_epsilon_machine( self, verbose=True ) :
1106
1107        if not ( self.is_unifilar() and self.is_strongly_connected() ) :
1108            if verbose : 
1109                print( f"Either non unifilar or not strongly connected" )
1110            return False
1111        else :
1112            return self._is_minimal_as_dfa( topological_only=False, verbose=verbose )
def minimize(self, retain_names: bool = True, verbose=False):
1118    def minimize(self, retain_names: bool = True, verbose=False):
1119
1120        """
1121        Minimizes the HMM, resulting in an :math:`\\epsilon-`machine if the HMM
1122        is unifilar and strongly connected. Converts the HMM to a DFA with symbols
1123        labeled jointly with symbols and probabilities, and uses Myhill-Nerode 
1124        equivalence for minimization. Relies on `automata_lib` and uses
1125         `automata.fa.dfa.DFA.minify` with `allow_partial=True`, and all states
1126         final.
1127
1128        Args:
1129            retain_names (bool): If `True`, the merged states will be named by their union, e.g. `{s_0, s_1}`, and other states will retain their origion names. Otherwise, they will be relabled `{ '0', '1', ..., 'n-1' }`.
1130
1131        Returns:
1132        
1133            automata.fa.dfa.DFA : the resulting DFA.
1134        """
1135
1136        if self._already_minimized :
1137            return
1138
1139        start = time.perf_counter()
1140
1141        if not self.is_unifilar():
1142            raise ValueError(
1143                "DFA minimization is not valid for non-unifilar HMMs"
1144            )
1145
1146        was_strongly_connected = self.is_strongly_connected()
1147        was_row_stochastic     = self.is_row_stochastic()
1148        was_unifilar           = self.is_unifilar()
1149
1150        n_states_before = len(self.states)
1151
1152        dfa = self.as_dfa(with_probs=True)
1153
1154        min_dfa = self.as_dfa(with_probs=True).minify(retain_names=True)
1155        #min_dfa = am_fast.minify_cpp( dfa, retain_names=True )
1156
1157        # Build lookup from original state index -> CausalState object
1158        orig_state   = {i: s for i, s in enumerate(self.states)}
1159        eq_list      = list(min_dfa.states)
1160
1161        start_eq = min_dfa.initial_state
1162        
1163        # Separate the start state, then sort the rest by the 
1164        # smallest original state index inside each equivalence class.
1165        other_eqs = [eq for eq in eq_list if eq != start_eq]
1166        other_eqs.sort(key=lambda eq: min(eq))
1167
1168        # Recombine so start eq comes first, followed by the sorted remaining classes
1169        eq_list = [start_eq] + other_eqs
1170        # ----------------------------------------------------------
1171
1172        # Recompute eq_to_idx with the new ordering
1173        eq_to_idx = {eq: i for i, eq in enumerate(eq_list)}
1174
1175        # new_start is now guaranteed to be 0
1176        new_start = 0
1177
1178        # Map each original state index -> its equivalence class
1179        # Guard: minify() silently drops unreachable states
1180        orig_to_eq = {s: eq for eq in min_dfa.states for s in eq}
1181
1182        # Build lookup from original state index -> its transitions
1183        orig_trs = defaultdict(list)
1184        for t in self.transitions:
1185            orig_trs[t.origin_state_idx].append(t)
1186
1187        new_trs = []
1188        for eq in min_dfa.states:
1189            rep        = next(iter(eq))
1190            origin_idx = eq_to_idx[eq]
1191            for t in orig_trs[rep]:
1192
1193                target_eq  = orig_to_eq[t.target_state_idx]
1194                target_idx = eq_to_idx[target_eq]
1195                
1196                new_trs.append(
1197                    t.modified_deep_copy(
1198                        origin_state_idx = origin_idx,
1199                        target_state_idx = target_idx
1200                    )
1201                )
1202
1203        members_list = [[orig_state[i] for i in sorted(eq)] for eq in eq_list]  # sorted for determinism
1204
1205        # Compute new names
1206        if retain_names:
1207            new_names = [
1208                "{" + ",".join(str(m.name) for m in members) + "}" if len(members) > 1
1209                else members[0].name
1210                for members in members_list
1211            ]
1212        else:
1213            new_names = [str(j) for j in range(len(eq_list))]
1214
1215        old_name_to_new_name = {
1216            m.name: new_names[j]
1217            for j, members in enumerate(members_list)
1218            for m in members
1219        }
1220
1221        # Build the new states, preserving classes and isomorphs regardless of naming
1222        new_states = []
1223        for j, (eq, members, name) in enumerate(zip(eq_list, members_list, new_names)):
1224            
1225            classes : defaultdict[int,set[str]]= {}
1226            for m in members:
1227                classes |= m.classes
1228            
1229            isomorphs = {
1230                old_name_to_new_name.get(iso, iso)
1231                for m in members
1232                for iso in m.isomorphs
1233                if old_name_to_new_name.get(iso, iso) != name
1234            }
1235            
1236            pseudo_isomorphs = {
1237                old_name_to_new_name.get(iso, iso)
1238                for m in members
1239                for iso in m.pseudo_isomorphs
1240                if old_name_to_new_name.get(iso, iso) != name
1241            }
1242
1243            new_states.append(CausalState(
1244                name      = name,
1245                classes   = classes,
1246                isomorphs = isomorphs,
1247                pseudo_isomorphs = pseudo_isomorphs
1248            ))
1249
1250        self.set_states(new_states)
1251        self.set_transitions(new_trs)
1252        self.start_state = new_start
1253
1254        if n_states_before == len(new_states) and verbose :
1255            print( f"{n_states_before} state HMM was already minimal.\n" )
1256        elif verbose :
1257            print( f"Minimized from {n_states_before} to {len(new_states)}\n" )
1258
1259        if not ( was_strongly_connected ==  self.is_strongly_connected() ) :
1260            raise RuntimeError(
1261                f"Minimization broke strongly connected"
1262            )
1263
1264        if not ( was_row_stochastic ==  self.is_row_stochastic() ) :
1265            raise RuntimeError(
1266                f"Minimization broke row stochasticity"
1267            )
1268
1269        if not( was_unifilar == self.is_unifilar() ) :
1270            raise RuntimeError(
1271                f"Minimization broke unifilarity"
1272            )
1273
1274        self._already_minimized = True

Minimizes the HMM, resulting in an \( \epsilon- \)machine if the HMM is unifilar and strongly connected. Converts the HMM to a DFA with symbols labeled jointly with symbols and probabilities, and uses Myhill-Nerode equivalence for minimization. Relies on automata_lib and uses automata.fa.dfa.DFA.minify with allow_partial=True, and all states final.

Arguments:
  • retain_names (bool): If True, the merged states will be named by their union, e.g. {s_0, s_1}, and other states will retain their origion names. Otherwise, they will be relabled { '0', '1', ..., 'n-1' }.
Returns:

automata.fa.dfa.DFA : the resulting DFA.

def collapse_to_largest_strongly_connected_subgraph(self, rename_states=True):
1281    def collapse_to_largest_strongly_connected_subgraph( self, rename_states=True ) :
1282
1283        was_q_weighted = self._has_valid_rational_probabilities
1284
1285        # get equivalent networkx graph
1286        G = self.as_digraph()
1287
1288        # if already strongly connected, nothing to do
1289        if not nx.is_strongly_connected( G ) :
1290
1291            start = time.perf_counter()
1292            subgraph_nodes = list( nx.strongly_connected_components( G ) )
1293
1294            # decompose into strongly connected components and sort by length
1295            # subgraph_nodes = list(nx.strongly_connected_components( G ))
1296            subgraph_nodes.sort(key=len)
1297            component_state_set = subgraph_nodes[-1]
1298
1299            # Take the largest strongly connected component (as list of state names)
1300            component_states = sorted( list( component_state_set ) )
1301
1302            # make temporary copies of the old transitions and states
1303            old_transitions = [
1304                tr.deepcopy()
1305                for tr in self.transitions
1306            ]
1307
1308            old_states = [
1309                s.deepcopy()
1310                for s in self.states
1311            ]
1312
1313            self.set_states(
1314                states=[ 
1315                    state
1316                    for i, state in enumerate( old_states ) if i in component_state_set
1317                ]
1318            )
1319
1320            # we will build new transition list based on those belonging to the component
1321            self.set_transitions( transitions= [] )
1322
1323            # for tracking which new transitions leave each state
1324            transitions_from_state = { state : set() for state in component_states }
1325            new_transitions = []
1326
1327            for tr in old_transitions :
1328
1329                origin_state_name = old_states[ tr.origin_state_idx ].name
1330                target_state_name = old_states[ tr.target_state_idx ].name
1331
1332                # skip transitions that connect separate strongly connected components
1333                if not ( tr.origin_state_idx in component_state_set and tr.target_state_idx in component_state_set ) :
1334                    continue
1335
1336                # track transitions (by index in new transitions list) that leave this state
1337                transitions_from_state[ tr.origin_state_idx ].add( len( new_transitions ) )
1338
1339                my_origin_state_idx = self.state_idx_map[ origin_state_name ]
1340                my_target_state_idx = self.state_idx_map[ target_state_name ]
1341
1342                new_transitions.append( 
1343                    tr.modified_deep_copy(  
1344                        origin_state_idx=my_origin_state_idx,
1345                        target_state_idx=my_target_state_idx
1346                    )
1347                )
1348
1349            self.set_transitions( transitions=new_transitions )
1350
1351            # if we removed an outgoing transition from a state, we need to distribute its probability 
1352            # among the remaining outgoing transitions from the state
1353            
1354            transition_list = list( self.transitions )
1355            
1356            for state in component_states :
1357                
1358                # get the set of transitions leaving this state
1359                state_trs = transitions_from_state[ state ]
1360
1361                # sum the probabilities of the outgoing transitions from the state
1362                p_sum = np.sum( [ self.transitions[ i ].prob for i in state_trs ] )
1363
1364                # how much probability is missing
1365                diff = 1.0 - p_sum
1366
1367                # if significant difference
1368                if abs( diff ) > self._EPS : 
1369
1370                    # calculate how much of the difference each transition gets
1371                    adjustment = diff / len( state_trs )
1372                    
1373                    new_transitions = []
1374
1375                    # update the transitions
1376                    for i in state_trs :
1377
1378                        # adjusted probability
1379                        transition_list[ i ] = self.transitions[ i ].modified_deep_copy(
1380                            prob=self.transitions[ i ].prob + adjustment,
1381                            pq=None
1382                        )
1383
1384            self.set_transitions( transition_list )
1385
1386            if rename_states :
1387                self.set_states( [
1388                    s.modified_deep_copy( name=f"{i}" )
1389                    for s in self.states
1390                ] )
1391
1392        if was_q_weighted :
1393            self.to_q_weighted()
def to_q_weighted(self, denominator_limit=1000):
1395    def to_q_weighted( self, denominator_limit=1000 ) :
1396
1397        """
1398        Approximates the existing transition probabilities with exact fractions, stores 
1399        the fractional probabilities as Fraction in Transition.pq, and sets the floating
1400        point probabilty to `float(pq)`. If `denominator_limit` is too small for a sane 
1401        conversion, the function recurses with `denominator_limit=denominator_limit*10`.
1402
1403        Args:
1404            denominator_limit (int): The initial input to :meth:`Fraction.limit_denominator` in 
1405            the conversion.
1406        """
1407
1408        if self._has_valid_rational_probabilities :
1409            return
1410
1411        if not self.is_row_stochastic() :
1412            raise ValueError( "Cannot convert to q-weighted because not row stochastic" )
1413
1414        t_from = [[] for _ in range(len(self.states))]
1415
1416        for i, tr in enumerate( self.transitions ) :
1417            t_from[ tr.origin_state_idx ].append( i )
1418
1419        new_transitions = []
1420        for t_list in t_from :
1421            
1422            if not t_list : 
1423                continue
1424
1425            p_q_sum = Fraction(0,1)
1426            p_qs = []
1427
1428            for t_idx in t_list :
1429            
1430                p_q = Fraction( self.transitions[ t_idx ].prob ).limit_denominator( denominator_limit )
1431                p_q_sum += p_q
1432                p_qs.append( p_q )
1433
1434            if p_q_sum != Fraction(1,1) :
1435                
1436                max_pq_i = np.argmax( p_qs )
1437                max_oq = p_qs[ max_pq_i ]
1438
1439                diff = p_q_sum - Fraction(1,1)
1440
1441                # If recurse with higher resolution
1442                if diff > max_oq :
1443                    return self.to_q_weighted( denominator_limit*10 )
1444                else :
1445                    p_qs[ max_pq_i ] -= diff
1446
1447            for i, t_idx in enumerate( t_list ) :	
1448                new_transitions.append( 
1449                    self.transitions[  t_idx ].modified_deep_copy(
1450                        prob=float(p_qs[ i ]),
1451                        pq=p_qs[ i ]
1452                    )
1453                )
1454
1455        self.set_transitions( new_transitions )
1456        self._has_valid_rational_probabilities = True

Approximates the existing transition probabilities with exact fractions, stores the fractional probabilities as Fraction in Transition.pq, and sets the floating point probabilty to float(pq). If denominator_limit is too small for a sane conversion, the function recurses with denominator_limit=denominator_limit*10.

Arguments:
  • denominator_limit (int): The initial input to Fraction.limit_denominator() in
  • the conversion.
def isomorphic_shift( self, input_symbol_indices: numpy.ndarray, input_state_indices: numpy.ndarray, shift: int = 1) -> dict[str, numpy.ndarray]:
1462    def isomorphic_shift(
1463        self,
1464        input_symbol_indices: np.ndarray,
1465        input_state_indices:  np.ndarray,
1466        shift : int = 1
1467    ) -> dict[str, np.ndarray]:
1468
1469        """
1470        Generates a new sequence of symbols that are permuted with the symbols emitted by
1471        isomorphic states, if they exists.
1472
1473        :math:`\\sigma_o = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i]\\right]`<br>
1474        :math:`\\sigma_t = \\mathcal{S}\\left[\\texttt{input\\_state\\_indices}[i+1]\\right]`
1475 
1476        :math:`\\mathcal{I}(\\sigma_o) = \\\\{\\sigma^0_o,\\, \\sigma^1_o,\\, \\dots,\\, \\sigma^{n-1}_o \\\\}`<br>
1477        :math:`\\mathcal{I}(\\sigma_t) = \\\\{\\sigma^0_t,\\, \\sigma^1_t,\\, \\dots,\\, \\sigma^{n-1}_t \\\\}`
1478 
1479        :math:`k = \\bigl(\\mathcal{I}(\\sigma_o).\\texttt{index}(\\sigma_o) + \\texttt{shift}\\bigr) \\bmod n`
1480 
1481        :math:`\\texttt{output\\_symbol\\_indices}[i]   := T(\\sigma_o^k,\\, \\sigma_t^k).\\text{symbol\\_index}`<br>
1482        :math:`\\texttt{output\\_state\\_indices}[i]    := \\mathcal{S}.\\texttt{index}(\\sigma_o^k)`<br>
1483        :math:`\\texttt{output\\_state\\_indices}[i+1]  := \\mathcal{S}.\\texttt{index}(\\sigma_t^k)`
1484
1485        Where :math:`\\mathcal{I}(\\sigma)`` is the ordered set of states isomorphic to :math:`\\sigma` including :math:`\\sigma` itself.
1486
1487        Args:
1488            input_symbol_indices (np.ndarray): The sequence of generated symbols.
1489            input_state_indices (np.ndarray): The sequence of states that generated symbols with the final state at the end.
1490            shift : int: How much to shift the symbols across the isomorphic states.
1491        """
1492        
1493        if not any( state.isomorphs for state in self.states ):
1494            raise ValueError("HMM has no states with isomorphs")
1495
1496        inputs = np.asarray(input_symbol_indices)
1497        states = np.asarray(input_state_indices)
1498
1499        n_states = len(self.states)
1500
1501        tr_sym_table    = np.full((n_states, n_states), -1, dtype=np.int32)
1502        is_pseudo_table = np.zeros((n_states, n_states), dtype=bool)
1503        tr_cross_table  = np.zeros((n_states, n_states), dtype=bool)
1504
1505        for tr in self.transitions:
1506            tr_sym_table[ tr.origin_state_idx, tr.target_state_idx ] = tr.symbol_idx
1507            tr_cross_table[tr.origin_state_idx, tr.target_state_idx] = tr.composition_depth > 0
1508
1509        for i, state in enumerate(self.states):
1510            for p_iso in state.pseudo_isomorphs:
1511                j = self.state_idx_map[p_iso]
1512                is_pseudo_table[i, j] = True
1513
1514        # Build isomorph remapping: identity by default, overridden where isomorphs exist
1515        iso_table = np.arange( n_states, dtype=np.int32 )
1516
1517        for i, state in enumerate(self.states):
1518            effective_isos = state.isomorphs | state.pseudo_isomorphs
1519            if len(effective_isos) > 0:
1520                isormorphs_with_identity = sorted([i] + [self.state_idx_map[iso] for iso in effective_isos])
1521                pos = isormorphs_with_identity.index(i)
1522                iso_table[i] = isormorphs_with_identity[(pos + shift) % len(isormorphs_with_identity)]
1523
1524        origins = states[:-1]
1525        targets = states[1:]
1526
1527        out_origins = iso_table[origins]
1528        out_targets = iso_table[targets]
1529
1530        rotated_symbols = tr_sym_table[ out_origins, out_targets ]
1531
1532        ###########################################################################
1533        # handle attempts to rotate symbols emitted by pseudo-isomorphic states
1534        
1535        # are the isomorphs used for the origin and target shift pseudo-isomorphism?
1536        origin_is_pseudo = is_pseudo_table[origins, out_origins]
1537        target_is_pseudo = is_pseudo_table[targets, out_targets]
1538
1539        # does the edge cross a component
1540        is_cross = tr_cross_table[ origins, targets ]
1541
1542        # is_cross is sufficient condition for single-level compositions, 
1543        # but condition the combined is required for multi-level composition
1544        invalid_shift = is_cross & origin_is_pseudo & target_is_pseudo
1545
1546        final_symbols = np.where( invalid_shift, inputs, rotated_symbols)
1547
1548        if np.any(final_symbols == -1):
1549            raise RuntimeError(
1550                "Invalid isomorphic shift: topology invariant violated."
1551            )
1552
1553        ############################################################################
1554
1555        invalid_state_shift = np.append( invalid_shift, invalid_shift[-1] )
1556
1557        rotated_states = np.empty(states.size, dtype=states.dtype)
1558        rotated_states[:-1] = out_origins
1559        rotated_states[-1]  = out_targets[-1]
1560
1561        rotated_states = np.where( invalid_state_shift, states, rotated_states )
1562
1563        return {
1564            "symbol_index": final_symbols.astype(inputs.dtype),
1565            "state_index":  rotated_states,
1566        }

Generates a new sequence of symbols that are permuted with the symbols emitted by isomorphic states, if they exists.

\( \sigma_o = \mathcal{S}\left[\texttt{input_state_indices}[i]\right] \)
\( \sigma_t = \mathcal{S}\left[\texttt{input_state_indices}[i+1]\right] \)

\( \mathcal{I}(\sigma_o) = \{\sigma^0_o,\, \sigma^1_o,\, \dots,\, \sigma^{n-1}_o \} \)
\( \mathcal{I}(\sigma_t) = \{\sigma^0_t,\, \sigma^1_t,\, \dots,\, \sigma^{n-1}_t \} \)

\( k = \bigl(\mathcal{I}(\sigma_o).\texttt{index}(\sigma_o) + \texttt{shift}\bigr) \bmod n \)

\( \texttt{output_symbol_indices}[i] := T(\sigma_o^k,\, \sigma_t^k).\text{symbol_index} \)
\( \texttt{output_state_indices}[i] := \mathcal{S}.\texttt{index}(\sigma_o^k) \)
\( \texttt{output_state_indices}[i+1] := \mathcal{S}.\texttt{index}(\sigma_t^k) \)

Where \( \mathcal{I}(\sigma) \)` is the ordered set of states isomorphic to \( \sigma \) including \( \sigma \) itself.

Arguments:
  • input_symbol_indices (np.ndarray): The sequence of generated symbols.
  • input_state_indices (np.ndarray): The sequence of states that generated symbols with the final state at the end.
  • shift : int: How much to shift the symbols across the isomorphic states.
def generate_belief_trajectory_from(self, symbols: numpy.ndarray):
1568    def generate_belief_trajectory_from( 
1569        self, 
1570        symbols : np.ndarray ) :
1571
1572        """
1573        Tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1574
1575        Returns:
1576            np.ndarray: The belief state sequence. 
1577        """
1578
1579        T_x = self.get_T_X()
1580        pi  = self.get_stationary_distribution()
1581
1582        mu        = pi.copy()
1583        states    = np.zeros(( len(symbols) + 1, len(mu)))
1584        states[0] = mu
1585        
1586        for i, x in enumerate( symbols ) :
1587            mu = mu @ T_x[x]
1588            mu = mu / mu.sum()
1589            states[i+1] = mu
1590        return states

Tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).

Returns:

np.ndarray: The belief state sequence.

def generate_belief_trajectory(self, n_steps: int, random_seed: int = 42) -> numpy.ndarray:
1592    def generate_belief_trajectory( self, 
1593        n_steps : int, 
1594        random_seed : int=42 ) -> np.ndarray:
1595        """
1596        Generates `n_steps` symbols from the HMM, then tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).
1597
1598        Returns:
1599            np.ndarray: The belief state sequence. 
1600        """
1601
1602        trs = self.get_transition_list()
1603
1604        data = am_fast.generate_data(
1605            n_gen=n_steps,
1606            start_state=self.start_state,
1607            transitions=trs,
1608            alphabet=sorted(list(self.alphabet)),
1609            include_states=False,
1610            random_seed=random_seed
1611        )
1612
1613        return self.generate_belief_trajectory_from( data["symbol_index"] )

Generates n_steps symbols from the HMM, then tracks belief states as the symbols are observed (Bayesian updates on state probability distribution).

Returns:

np.ndarray: The belief state sequence.

def generate_data( self, file_prefix: str, n_gen: int, include_states: bool, row_size: int | None = None, include_belief_states: bool = False, isomorphic_shifts: set[int] | None = None, with_component_map: bool = True, random_seed: int = 42) -> dict[str, typing.Any]:
1615    def generate_data(
1616        self,
1617        file_prefix: str,
1618        n_gen: int,
1619        include_states: bool,
1620        row_size : int | None = None,
1621        include_belief_states : bool=False,
1622        isomorphic_shifts : set[int] | None = None,
1623        with_component_map : bool = True, 
1624        random_seed : int=42 ) -> dict[str,Any] : 
1625
1626        if isomorphic_shifts is not None and not include_states :
1627            raise ValueError( "Isomorphic inversion requires include_states=True" )
1628
1629        trs = self.get_transition_list()
1630
1631        data = am_fast.generate_data(
1632            n_gen=n_gen,
1633            start_state=self.start_state,
1634            transitions=trs,
1635            alphabet=sorted(list(self.alphabet)),
1636            include_states=include_states,
1637            random_seed=random_seed
1638        )
1639
1640        if isomorphic_shifts is not None :
1641
1642            data[ "isomorphic_shifts" ] = {}
1643
1644            for shift in isomorphic_shifts :
1645
1646                try : 
1647
1648                    shifted = self.isomorphic_shift(
1649                        input_symbol_indices=data[ "symbol_index" ], 
1650                        input_state_indices=data[ "state_index" ], 
1651                        shift=shift
1652                    )
1653
1654                    data[ "isomorphic_shifts" ][ f"{shift}" ] = {
1655                        "symbol_index" : shifted[ "symbol_index" ],
1656                        "state_index"  : shifted[ "state_index" ]
1657                    }
1658
1659                except Exception as e :
1660                    print( f"Exception {e}" )
1661
1662        if include_belief_states :
1663            belief_states = self.generate_belief_trajectory_from( data[ "symbol_index" ] )
1664            data[ "belief_states" ] = belief_states
1665
1666        metadata = self.get_metadata()
1667
1668        # Maps global state index to the component instance id the state belongs to at 
1669        # a given depth in the composition. 
1670        # somewhat hackish, namely the string based encoding, parsing, and fact that 
1671        # class cmi is only added in compositionally constructed machines
1672        if with_component_map :
1673            
1674            cmp_map = defaultdict(dict)
1675            all_cmis : set[tuple[str,str]] = set()
1676            
1677            for state_index, state in enumerate( self.states ) : 
1678                for level, l_classes in state.classes.items() :
1679                    
1680                    cmi = None
1681
1682                    level_str = str(level)
1683                    state_idx_str = str(state_index)
1684
1685                    for cls in l_classes : 
1686                        cls_tpy = cls.split( "_" )[ 0 ] 
1687                        if cls_tpy == "cmi" :
1688                            cmi = cls
1689                            break
1690                    
1691                    if cmi is not None : 
1692                        cmp_map[ state_idx_str ][ level_str ] = cmi
1693
1694                    if state_idx_str in cmp_map :
1695                        all_cmis.add( ( level_str, cmp_map[ state_idx_str ][ level_str ] ) )
1696            
1697            cmi_indices : dict[ str, dict[str, int ] ] = defaultdict(dict)
1698            for idx, cmi in enumerate( sorted( all_cmis ) ) :
1699                cmi_indices[ cmi[0] ][ cmi[1] ] = idx
1700
1701            metadata["state_component_map" ] = cmp_map
1702            metadata["all_components"      ] = sorted(all_cmis)
1703            metadata["component_indices"   ] = cmi_indices
1704
1705        if "state_index" in data :
1706            index_histogram(
1707                data=data["state_index"], 
1708                output_path=file_prefix + "_state_frequency",
1709                title="State Frequency",
1710                x_label="state index",
1711                show=False
1712            )
1713
1714        am_fast.save_data(
1715            data=data,
1716            file_prefix=file_prefix,
1717            alphabet=sorted(list(self.alphabet)),
1718            n_states=len( self.states ),
1719            start_state=self.start_state,
1720            random_seed=random_seed,
1721            row_size=row_size,
1722            machine_metadata=metadata )
1723
1724        return data
def as_digraph(self) -> networkx.classes.digraph.DiGraph:
1730    def as_digraph( self ) -> nx.DiGraph :
1731
1732        """
1733        Builds a [networkx.DiGraph](https://networkx.org/documentation/stable/reference/classes/digraph.html) constructed from the machine's transitions with no edge symbols or weights.
1734
1735        Returns:
1736        
1737            networkx.DiGraph : the resulting graph.
1738        """
1739
1740        G = nx.DiGraph()
1741        G.add_nodes_from( [ i for i, s in enumerate( self.states ) ] )
1742
1743        for tr in self.transitions :
1744            G.add_edge( tr.origin_state_idx, tr.target_state_idx )
1745
1746        return G

Builds a networkx.DiGraph constructed from the machine's transitions with no edge symbols or weights.

Returns:

networkx.DiGraph : the resulting graph.

def as_dfa(self, with_probs: bool):
1748    def as_dfa( self, with_probs : bool ) :
1749
1750        """
1751        Builds an [automata.fa.dfa.DFA](https://caleb531.github.io/automata/api/fa/class-dfa/) constructed from the machine's transitions.
1752
1753        Args:
1754            with_probs (bool): If true the DFA transitions are labeled based on
1755                the symbol of the machines transition concatenated with its
1756                probability, othwise, the only the symbols.
1757
1758        Returns:
1759        
1760            automata.fa.dfa.DFA : the resulting DFA.
1761        """
1762
1763        precision=8
1764
1765        def edge_label( symb, prob ) :
1766            return f"({symb},{round(prob, precision)})"
1767
1768        # Build states, symbols, and transitions 
1769        dfa_states  = { i for i, _ in enumerate( self.states ) }
1770            
1771        if not with_probs :
1772            dfa_symbols = set( { str(t.symbol_idx) for t in self.transitions } )
1773        else : 
1774            dfa_symbols = set( { edge_label( t.symbol_idx, t.prob ) for t in self.transitions } )
1775
1776        dfa_transitions = defaultdict(dict)
1777
1778        if not with_probs :
1779            for t in self.transitions :
1780                dfa_transitions[ t.origin_state_idx ][ t.symbol_idx ] = t.target_state_idx
1781        else :
1782            for t in self.transitions :
1783                dfa_transitions[ t.origin_state_idx ][ edge_label( t.symbol_idx, t.prob ) ] = t.target_state_idx
1784
1785        # Construct the DFA
1786        return DFA(
1787            states=dfa_states,
1788            input_symbols=dfa_symbols,
1789            transitions=dfa_transitions,
1790            initial_state=self.start_state,
1791            allow_partial=True,
1792            final_states={ 
1793                s for s in dfa_states
1794            }
1795        )

Builds an automata.fa.dfa.DFA constructed from the machine's transitions.

Arguments:
  • with_probs (bool): If true the DFA transitions are labeled based on the symbol of the machines transition concatenated with its probability, othwise, the only the symbols.
Returns:

automata.fa.dfa.DFA : the resulting DFA.