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 )
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.
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
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.
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 )
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()
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.
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 )
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
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
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
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()
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
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
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
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
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 )
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} \).
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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} \).
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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) \).
-
James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018. https://arxiv.org/abs/1105.2988 ↩
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} \)
-
James et al., Anatomy of a Bit: Information in a Time Series Observation, 2018. https://arxiv.org/abs/1105.2988 ↩
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.

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.
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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} \)
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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} \)
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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} \)
-
Crutchfield et al., Exact Complexity: The Spectral Decomposition of Intrinsic Computation, 2016. https://arxiv.org/abs/1309.3792 ↩
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
$$\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 \)
-
Crutchfield et al., Time’s barbed arrow: Irreversibility, crypticity, and stored information, 2009. https://arxiv.org/abs/0902.1209 ↩
-
Mahoney et al., Information Accessibility and Cryptic Processes, 2021. https://arxiv.org/abs/0905.4787 ↩
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.
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.
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.
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."
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.
-
Johnson et al, Enumerating Finitary Processes, 2024. https://arxiv.org/abs/1011.0036 ↩
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 )
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.
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()
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.
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.
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.
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.
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
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.
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.