# -*- coding: utf-8 -*-
__all__ = [
'MarkovChain'
]
###########
# IMPORTS #
###########
# Major
import networkx as nx
import numpy as np
import numpy.linalg as npl
import numpy.random as npr
import numpy.random.mtrand as nprm
import scipy.integrate as spi
import scipy.optimize as spo
import scipy.stats as sps
# Minor
from copy import (
deepcopy
)
from inspect import (
getmembers,
isfunction,
stack,
trace
)
from itertools import (
chain
)
from json import (
dump,
load
)
from math import (
gcd,
lgamma
)
# Internal
from .base_class import (
BaseClass
)
from .custom_types import *
from .decorators import (
alias,
aliased,
cachedproperty
)
from .exceptions import (
ValidationError
)
from .validation import (
validate_boolean,
validate_dictionary,
validate_enumerator,
validate_float,
validate_hyperparameter,
validate_integer,
validate_interval,
validate_mask,
validate_matrix,
validate_rewards,
validate_state,
validate_state_names,
validate_states,
validate_status,
validate_string,
validate_transition_function,
validate_transition_matrix,
validate_transition_matrix_size,
validate_vector
)
###########
# CLASSES #
###########
[docs]@aliased
class MarkovChain(metaclass=BaseClass):
"""
Defines a Markov chain with given transition matrix and state names.
:param p: the transition matrix.
:param states: the name of each state (if omitted, an increasing sequence of integers starting at 1).
:param order: the order of the process (if omitted, 1).
:raises ValidationError: if any input argument is not compliant.
"""
def __init__(self, p: tnumeric, states: olist_str = None, order: oint = None):
caller = stack()[1][3]
sm = [x[1].__name__ for x in getmembers(MarkovChain, predicate=isfunction) if x[1].__name__[0] != '_' and isinstance(MarkovChain.__dict__.get(x[1].__name__), staticmethod)]
if caller not in sm:
try:
p = validate_transition_matrix(p)
if states is None:
states = [str(i) for i in range(1, p.shape[0] + 1)]
else:
states = validate_state_names(states, p.shape[0])
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
self._digraph: tgraph = nx.DiGraph(p)
self._order: int = order
self._p: tarray = p
self._size: int = p.shape[0]
self._states: tlist_str = states
def __eq__(self, other):
if isinstance(other, MarkovChain):
return np.array_equal(self.p, other.p) and (self.states == other.states)
return NotImplemented
def __hash__(self):
return hash((self.p.tobytes(), tuple(self.states)))
def __repr__(self) -> str:
return self.__class__.__name__
# noinspection PyListCreation
def __str__(self) -> str:
lines = []
lines.append('')
lines.append('DISCRETE-TIME MARKOV CHAIN')
lines.append(f' ORDER: {self._order:d}')
lines.append(f' SIZE: {self._size:d}')
lines.append(f' CLASSES: {len(self.communicating_classes):d}')
lines.append(f' - RECURRENT: {len(self.recurrent_classes):d}')
lines.append(f' - TRANSIENT: {len(self.transient_classes):d}')
lines.append(f' ABSORBING: {("YES" if self.is_absorbing else "NO")}')
lines.append(f' APERIODIC: {("YES" if self.is_aperiodic else "NO (" + str(self.period) + ")")}')
lines.append(f' ERGODIC: {("YES" if self.is_ergodic else "NO")}')
lines.append(f' IRREDUCIBLE: {("YES" if self.is_irreducible else "NO")}')
lines.append(f' REGULAR: {("YES" if self.is_regular else "NO")}')
lines.append(f' REVERSIBLE: {("YES" if self.is_reversible else "NO")}')
lines.append(f' SYMMETRIC: {("YES" if self.is_symmetric else "NO")}')
lines.append('')
return '\n'.join(lines)
@cachedproperty
def _absorbing_states_indices(self) -> tlist_int:
return [i for i in range(self._size) if np.isclose(self._p[i, i], 1.0)]
@cachedproperty
def _classes_indices(self) -> tlists_int:
return [sorted([index for index in component]) for component in nx.strongly_connected_components(self._digraph)]
@cachedproperty
def _communicating_classes_indices(self) -> tlists_int:
return sorted(self._classes_indices, key=lambda x: (-len(x), x[0]))
@cachedproperty
def _cyclic_classes_indices(self) -> tlists_int:
if not self.is_irreducible:
return list()
if self.is_aperiodic:
return self._communicating_classes_indices.copy()
v = np.zeros(self._size, dtype=int)
v[0] = 1
w = np.array([], dtype=int)
t = np.array([0], dtype=int)
d = 0
m = 1
while (m > 0) and (d != 1):
i = t[0]
j = 0
t = np.delete(t, 0)
w = np.append(w, i)
while j < self._size:
if self._p[i, j] > 0.0:
r = np.append(w, t)
k = np.sum(r == j)
if k > 0:
b = v[i] - v[j] + 1
d = gcd(d, b)
else:
t = np.append(t, j)
v[j] = v[i] + 1
j += 1
m = t.size
v = np.remainder(v, d)
indices = list()
for u in np.unique(v):
indices.append(list(chain.from_iterable(np.argwhere(v == u))))
return sorted(indices, key=lambda x: (-len(x), x[0]))
@cachedproperty
def _cyclic_states_indices(self) -> tlist_int:
return sorted(list(chain.from_iterable(self._cyclic_classes_indices)))
@cachedproperty
def _recurrent_classes_indices(self) -> tlists_int:
indices = [index for index in self._classes_indices if index not in self._transient_classes_indices]
return sorted(indices, key=lambda x: (-len(x), x[0]))
@cachedproperty
def _recurrent_states_indices(self) -> tlist_int:
return sorted(list(chain.from_iterable(self._recurrent_classes_indices)))
@cachedproperty
def _slem(self) -> ofloat:
if not self.is_ergodic:
return None
values = npl.eigvals(self._p)
values_abs = np.sort(np.abs(values))
values_ct1 = np.isclose(values_abs, 1.0)
if np.all(values_ct1):
return None
slem = values_abs[~values_ct1][-1]
if np.isclose(slem, 0.0):
return None
return slem
@cachedproperty
def _states_indices(self) -> tlist_int:
return list(range(self._size))
@cachedproperty
def _transient_classes_indices(self) -> tlists_int:
edges = set([edge1 for (edge1, edge2) in nx.condensation(self._digraph).edges])
indices = [self._classes_indices[edge] for edge in edges]
return sorted(indices, key=lambda x: (-len(x), x[0]))
@cachedproperty
def _transient_states_indices(self) -> tlist_int:
return sorted(list(chain.from_iterable(self._transient_classes_indices)))
[docs] @cachedproperty
def absorbing_states(self) -> tlists_str:
"""
A property representing the absorbing states of the Markov chain.
"""
return [*map(self._states.__getitem__, self._absorbing_states_indices)]
[docs] @cachedproperty
def absorption_probabilities(self) -> oarray:
"""
A property representing the absorption probabilities of the Markov chain. If the Markov chain is not *absorbing* and has no transient states, then None is returned.
"""
if self.is_absorbing:
n = self.fundamental_matrix
absorbing_indices = self._absorbing_states_indices
transient_indices = self._transient_states_indices
r = self._p[np.ix_(transient_indices, absorbing_indices)]
return np.transpose(np.matmul(n, r))
if len(self.transient_states) > 0:
n = self.fundamental_matrix
recurrent_indices = self._recurrent_classes_indices
transient_indices = self._transient_states_indices
r = np.zeros((len(transient_indices), len(recurrent_indices)), dtype=float)
for i, transient_state in enumerate(transient_indices):
for j, recurrent_class in enumerate(recurrent_indices):
r[i, j] = np.sum(self._p[transient_state, :][:, recurrent_class])
return np.transpose(np.matmul(n, r))
return None
[docs] @cachedproperty
def absorption_times(self) -> oarray:
"""
A property representing the absorption times of the Markov chain. If the Markov chain is not *absorbing*, then None is returned.
"""
if not self.is_absorbing:
return None
n = self.fundamental_matrix
return np.transpose(np.dot(n, np.ones(n.shape[0], dtype=float)))
[docs] @cachedproperty
def accessibility_matrix(self) -> tarray:
"""
A property representing the accessibility matrix of the Markov chain.
"""
a = self.adjacency_matrix
i = np.eye(self._size, dtype=int)
m = (i + a) ** (self._size - 1)
m = (m > 0).astype(int)
return m
[docs] @cachedproperty
def adjacency_matrix(self) -> tarray:
"""
A property representing the adjacency matrix of the Markov chain.
"""
return (self._p > 0.0).astype(int)
[docs] @cachedproperty
def communicating_classes(self) -> tlists_str:
"""
A property representing the communicating classes of the Markov chain.
"""
return [[*map(self._states.__getitem__, i)] for i in self._communicating_classes_indices]
[docs] @cachedproperty
def cyclic_classes(self) -> tlists_str:
"""
A property representing the cyclic classes of the Markov chain.
"""
return [[*map(self._states.__getitem__, i)] for i in self._cyclic_classes_indices]
[docs] @cachedproperty
def cyclic_states(self) -> tlists_str:
"""
A property representing the cyclic states of the Markov chain.
"""
return [*map(self._states.__getitem__, self._cyclic_states_indices)]
[docs] @cachedproperty
def determinant(self) -> float:
"""
A property representing the determinant the transition matrix of the Markov chain.
"""
return npl.det(self._p)
[docs] @cachedproperty
def entropy_rate(self) -> ofloat:
"""
A property representing the entropy rate of the Markov chain. If the Markov chain is not *ergodic*, then None is returned.
"""
if not self.is_ergodic:
return None
p = self._p.copy()
pi = self.pi[0]
h = 0.0
for i in range(self._size):
for j in range(self._size):
if p[i, j] > 0.0:
h += pi[i] * p[i, j] * np.log(p[i, j])
return -h
[docs] @cachedproperty
def entropy_rate_normalized(self) -> ofloat:
"""
A property representing the entropy rate, normalized between 0 and 1, of the Markov chain. If the Markov chain is not *ergodic*, then None is returned.
"""
if not self.is_ergodic:
return None
values = npl.eigvalsh(self.adjacency_matrix)
values_abs = np.sort(np.abs(values))
return self.entropy_rate / np.log(values_abs[-1])
[docs] @cachedproperty
def fundamental_matrix(self) -> oarray:
"""
A property representing the fundamental matrix of the Markov chain. If the Markov chain has no transient states, then None is returned.
"""
if len(self.transient_states) == 0:
return None
indices = self._transient_states_indices
q = self._p[np.ix_(indices, indices)]
i = np.eye(len(indices), dtype=float)
return npl.inv(i - q)
[docs] @cachedproperty
def is_absorbing(self) -> bool:
"""
A property indicating whether the Markov chain is absorbing.
"""
if len(self.absorbing_states) == 0:
return False
indices = set(self._states_indices)
absorbing_indices = set(self._absorbing_states_indices)
transient_indices = set()
progress = True
unknown_states = None
while progress:
unknown_states = indices.copy() - absorbing_indices - transient_indices
known_states = absorbing_indices | transient_indices
progress = False
for i in unknown_states:
for j in known_states:
if self._p[i, j] > 0.0:
transient_indices.add(i)
progress = True
break
return len(unknown_states) == 0
[docs] @cachedproperty
def is_aperiodic(self) -> bool:
"""
A property indicating whether the Markov chain is aperiodic.
"""
if self.is_irreducible:
return self.periods[0] == 1
return nx.is_aperiodic(self._digraph)
[docs] @cachedproperty
def is_canonical(self) -> bool:
"""
A property indicating whether the Markov chain has a canonical form.
"""
recurrent_indices = self._recurrent_states_indices
transient_indices = self._transient_states_indices
if (len(recurrent_indices) == 0) or (len(transient_indices) == 0):
return True
return max(transient_indices) < min(recurrent_indices)
[docs] @cachedproperty
def is_ergodic(self) -> bool:
"""
A property indicating whether the Markov chain is ergodic or not.
"""
return self.is_aperiodic and self.is_irreducible
[docs] @cachedproperty
def is_irreducible(self) -> bool:
"""
A property indicating whether the Markov chain is irreducible.
"""
return len(self.communicating_classes) == 1
[docs] @cachedproperty
def is_regular(self) -> bool:
"""
A property indicating whether the Markov chain is regular.
"""
values = npl.eigvals(self._p)
values_abs = np.sort(np.abs(values))
values_ct1 = np.isclose(values_abs, 1.0)
return values_ct1[0] and not any(values_ct1[1:])
[docs] @cachedproperty
def is_reversible(self) -> bool:
"""
A property indicating whether the Markov chain is reversible.
"""
if not self.is_ergodic:
return False
pi = self.pi[0]
x = pi[:, np.newaxis] * self._p
return np.allclose(x, np.transpose(x), atol=1e-10)
[docs] @cachedproperty
def is_symmetric(self) -> bool:
"""
A property indicating whether the Markov chain is symmetric.
"""
return np.allclose(self._p, np.transpose(self._p), atol=1e-10)
[docs] @cachedproperty
def kemeny_constant(self) -> ofloat:
"""
A property representing the Kemeny's constant of the fundamental matrix of the Markov chain. If the Markov chain is not *absorbing*, then None is returned.
"""
if not self.is_absorbing:
return None
n = self.fundamental_matrix
return np.asscalar(np.trace(n))
[docs] @alias('mfpt')
@cachedproperty
def mean_first_passage_times(self) -> oarray:
"""
A property representing the mean first passage times of the Markov chain. If the Markov chain is not *ergodic*, then None is returned.
| **Aliases:** mfpt
"""
if not self.is_ergodic:
return None
a = np.tile(self.pi[0], (self._size, 1))
i = np.eye(self._size, dtype=float)
z = npl.inv(i - self._p + a)
e = np.ones((self._size, self._size), dtype=float)
k = np.dot(e, np.diag(np.diag(z)))
return np.dot(i - z + k, np.diag(1.0 / np.diag(a)))
[docs] @cachedproperty
def mixing_rate(self) -> ofloat:
"""
A property representing the mixing rate of the Markov chain. If the *SLEM* (second largest eigenvalue modulus) cannot be computed, then None is returned.
"""
if self._slem is None:
return None
return -1.0 / np.log(self._slem)
@property
def order(self) -> int:
"""
A property representing the order of the Markov chain.
"""
return self._order
@property
def p(self) -> tarray:
"""
A property representing the transition matrix of the Markov chain.
"""
return self._p
[docs] @cachedproperty
def period(self) -> int:
"""
A property representing the period of the Markov chain.
"""
if self.is_aperiodic:
return 1
if self.is_irreducible:
return self.periods[0]
period = 1
for p in [self.periods[self.communicating_classes.index(rc)] for rc in self.recurrent_classes]:
period = (period * p) // gcd(period, p)
return period
[docs] @cachedproperty
def periods(self) -> tlist_int:
"""
A property representing the period of each communicating class defined by the Markov chain.
"""
periods = [0] * len(self._communicating_classes_indices)
for sccs in nx.strongly_connected_components(self._digraph):
sccs_reachable = sccs.copy()
for scc_reachable in sccs_reachable:
spl = nx.shortest_path_length(self._digraph, scc_reachable).keys()
sccs_reachable = sccs_reachable.union(spl)
index = self._communicating_classes_indices.index(sorted(list(sccs)))
if (sccs_reachable - sccs) == set():
periods[index] = MarkovChain._calculate_period(self._digraph.subgraph(sccs))
else:
periods[index] = 1
return periods
[docs] @alias('stationary_distributions', 'steady_states')
@cachedproperty
def pi(self) -> tlist_array:
"""
A property representing the stationary distributions of the Markov chain.
| **Aliases:** stationary_distributions, steady_states
"""
if self.is_irreducible:
s = np.reshape(MarkovChain._gth_solve(self._p), (1, self._size))
else:
s = np.zeros((len(self.recurrent_classes), self._size), dtype=float)
for i, indices in enumerate(self._recurrent_classes_indices):
pr = self._p[np.ix_(indices, indices)]
s[i, indices] = MarkovChain._gth_solve(pr)
pi = list()
for i in range(s.shape[0]):
pi.append(s[i, :])
return pi
[docs] @cachedproperty
def rank(self) -> int:
"""
A property representing the rank of the transition matrix of the Markov chain.
"""
return npl.matrix_rank(self._p)
[docs] @cachedproperty
def recurrence_times(self) -> oarray:
"""
A property representing the recurrence times of the Markov chain. If the Markov chain has no recurrent states, then None is returned.
"""
if len(self._recurrent_states_indices) == 0:
return None
pi = np.vstack(self.pi)
rts = []
for i in range(pi.shape[0]):
for j in range(pi.shape[1]):
if not np.isclose(pi[i, j], 0.0):
rts.append(1.0 / pi[i, j])
return np.array(rts)
[docs] @cachedproperty
def recurrent_classes(self) -> tlists_str:
"""
A property representing the recurrent classes defined by the Markov chain.
"""
return [[*map(self._states.__getitem__, i)] for i in self._recurrent_classes_indices]
[docs] @cachedproperty
def recurrent_states(self) -> tlists_str:
"""
A property representing the recurrent states of the Markov chain.
"""
return [*map(self._states.__getitem__, self._recurrent_states_indices)]
[docs] @cachedproperty
def relaxation_rate(self) -> ofloat:
"""
A property representing the relaxation rate of the Markov chain. If the *SLEM* (second largest eigenvalue modulus) cannot be computed, then None is returned.
"""
if self._slem is None:
return None
return 1.0 / (1.0 - self._slem)
@property
def size(self) -> int:
"""
A property representing the size of the Markov chain.
"""
return self._size
[docs] @cachedproperty
def spectral_gap(self) -> ofloat:
"""
A property representing the spectral gap of the Markov chain. If the Markov chain is not *ergodic*, then None is returned.
"""
if not self.is_ergodic:
return None
values = npl.eigvals(self._p)
values = values.astype(complex)
values = np.unique(np.append(values, np.array([1.0]).astype(complex)))
values = np.sort(np.abs(values))[::-1]
return values[0] - values[1]
@property
def states(self) -> tlist_str:
"""
A property representing the states of the Markov chain.
"""
return self._states
[docs] @cachedproperty
def topological_entropy(self) -> float:
"""
A property representing the topological entropy of the Markov chain.
"""
values = npl.eigvals(self.adjacency_matrix)
values_abs = np.sort(np.abs(values))
return np.log(values_abs[-1])
[docs] @cachedproperty
def transient_classes(self) -> tlists_str:
"""
A property representing the transient classes defined by the Markov chain.
"""
return [[*map(self._states.__getitem__, i)] for i in self._transient_classes_indices]
[docs] @cachedproperty
def transient_states(self) -> tlists_str:
"""
A property representing the transient states of the Markov chain.
"""
return [*map(self._states.__getitem__, self._transient_states_indices)]
[docs] def are_communicating(self, state1: tstate, state2: tstate) -> bool:
"""
The method verifies whether the given states of the Markov chain are communicating.
:param state1: the first state.
:param state2: the second state.
:return: True if the given states are communicating, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state1 = validate_state(state1, self._states)
state2 = validate_state(state2, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
a1 = self.accessibility_matrix[state1, state2] != 0
a2 = self.accessibility_matrix[state2, state1] != 0
return a1 and a2
[docs] @alias('backward_committor')
def backward_committor_probabilities(self, states1: tstates, states2: tstates) -> oarray:
"""
The method computes the backward committor probabilities between the given subsets of the state space defined by the Markov chain.
| **Aliases:** backward_committor
:param states1: the first subset of states.
:param states2: the second subset of states.
:return: the backward committor probabilities if the Markov chain is *ergodic*, None otherwise.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the two sets are not disjoint.
"""
try:
states1 = validate_states(states1, self._states, 'subset', True)
states2 = validate_states(states2, self._states, 'subset', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if not self.is_ergodic:
return None
intersection = [s for s in states1 if s in states2]
if len(intersection) > 0:
raise ValueError(f'The two sets of states must be disjoint. An intersection has been detected: {", ".join([str(i) for i in intersection])}.')
a = np.transpose(self.pi[0][:, np.newaxis] * (self._p - np.eye(self._size, dtype=float)))
a[states1, :] = 0.0
a[states1, states1] = 1.0
a[states2, :] = 0.0
a[states2, states2] = 1.0
b = np.zeros(self._size, dtype=float)
b[states1] = 1.0
cb = npl.solve(a, b)
cb[np.isclose(cb, 0.0)] = 0.0
return cb
[docs] @alias('conditional_distribution')
def conditional_probabilities(self, state: tstate) -> tarray:
"""
The method computes the probabilities, for all the states of the Markov chain, conditioned on the process being at a given state.
| **Aliases:** conditional_distribution
:param state: the current state.
:return: the conditional probabilities of the Markov chain states.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return self._p[state, :]
[docs] def expected_rewards(self, steps: int, rewards: tnumeric) -> tarray:
"""
The method computes the expected rewards of the Markov chain after N steps, given the reward value of each state.
:param steps: the number of steps.
:param rewards: the reward values.
:return: the expected rewards of each state of the Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
rewards = validate_rewards(rewards, self._size)
steps = validate_integer(steps, lower_limit=(0, True))
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
original_rewards = rewards.copy()
for i in range(steps):
rewards = original_rewards + np.dot(rewards, self._p)
return rewards
[docs] def expected_transitions(self, steps: int, initial_distribution: onumeric = None) -> oarray:
"""
The method computes the expected number of transitions performed by the Markov chain after N steps, given the initial distribution of the states.
:param steps: the number of steps.
:param initial_distribution: the initial distribution of the states (if omitted, the states are assumed to be uniformly distributed).
:return: the expected number of transitions on each state of the Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
steps = validate_integer(steps, lower_limit=(0, True))
if initial_distribution is None:
initial_distribution = np.ones(self._size, dtype=float) / self._size
else:
initial_distribution = validate_vector(initial_distribution, 'stochastic', False, size=self._size)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if steps <= self._size:
pi = initial_distribution
p_sum = initial_distribution
for i in range(steps - 1):
pi = np.dot(pi, self._p)
p_sum += pi
expected_transitions = p_sum[:, np.newaxis] * self._p
else:
values, rvecs = npl.eig(self._p)
indices = np.argsort(np.abs(values))[::-1]
d = np.diag(values[indices])
rvecs = rvecs[:, indices]
lvecs = npl.solve(np.transpose(rvecs), np.eye(self._size, dtype=float))
lvecs_sum = np.sum(lvecs[:, 0])
if not np.isclose(lvecs_sum, 0.0):
rvecs[:, 0] = rvecs[:, 0] * lvecs_sum
lvecs[:, 0] = lvecs[:, 0] / lvecs_sum
q = np.asarray(np.diagonal(d))
if np.isscalar(q):
ds = steps if np.isclose(q, 1.0) else (1.0 - (q ** steps)) / (1.0 - q)
else:
ds = np.zeros(np.shape(q), dtype=q.dtype)
indices_et1 = (q == 1.0)
ds[indices_et1] = steps
ds[~indices_et1] = (1.0 - q[~indices_et1] ** steps) / (1.0 - q[~indices_et1])
ds = np.diag(ds)
ts = np.dot(np.dot(rvecs, ds), np.conjugate(np.transpose(lvecs)))
ps = np.dot(initial_distribution, ts)
expected_transitions = np.real(ps[:, np.newaxis] * self._p)
return expected_transitions
[docs] @alias('forward_committor')
def forward_committor_probabilities(self, states1: tstates, states2: tstates) -> oarray:
"""
The method computes the forward committor probabilities between the given subsets of the state space defined by the Markov chain.
| **Aliases:** forward_committor
:param states1: the first subset of states.
:param states2: the second subset of states.
:return: the forward committor probabilities if the Markov chain is *ergodic*, None otherwise.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the two sets are not disjoint.
"""
try:
states1 = validate_states(states1, self._states, 'subset', True)
states2 = validate_states(states2, self._states, 'subset', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if not self.is_ergodic:
return None
intersection = [s for s in states1 if s in states2]
if len(intersection) > 0:
raise ValueError(f'The two sets of states must be disjoint. An intersection has been detected: {", ".join([str(i) for i in intersection])}.')
a = self._p - np.eye(self._size, dtype=float)
a[states1, :] = 0.0
a[states1, states1] = 1.0
a[states2, :] = 0.0
a[states2, states2] = 1.0
b = np.zeros(self._size, dtype=float)
b[states2] = 1.0
cf = npl.solve(a, b)
cf[np.isclose(cf, 0.0)] = 0.0
return cf
[docs] def hitting_probabilities(self, states: ostates = None) -> tarray:
"""
The method computes the hitting probability, for all the states of the Markov chain, to the given set of states.
:param states: the set of target states (if omitted, all the states are targeted).
:return: the hitting probability of each state of the Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
if states is None:
states = self._states_indices.copy()
else:
states = validate_states(states, self._states, 'regular', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
states = sorted(states)
target = np.array(states)
non_target = np.setdiff1d(np.arange(self._size, dtype=int), target)
stable = np.ravel(np.where(np.isclose(np.diag(self._p), 1.0)))
origin = np.setdiff1d(non_target, stable)
a = self._p[origin, :][:, origin] - np.eye((len(origin)), dtype=float)
b = np.sum(-self._p[origin, :][:, target], axis=1)
x = npl.solve(a, b)
hp = np.ones(self._size, dtype=float)
hp[origin] = x
hp[states] = 1.0
hp[stable] = 0.0
return hp
[docs] def is_absorbing_state(self, state: tstate) -> bool:
"""
The method verifies whether the given state of the Markov chain is absorbing.
:param state: the target state.
:return: True if the state is absorbing, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return state in self._absorbing_states_indices
[docs] def is_accessible(self, state_target: tstate, state_origin: tstate) -> bool:
"""
The method verifies whether the given target state is reachable from the given origin state.
:param state_target: the target state.
:param state_origin: the origin state.
:return: True if the target state is reachable from the origin state, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state_target = validate_state(state_target, self._states)
state_origin = validate_state(state_origin, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return self.accessibility_matrix[state_origin, state_target] != 0
[docs] def is_cyclic_state(self, state: tstate) -> bool:
"""
The method verifies whether the given state is cyclic.
:param state: the target state.
:return: True if the state is cyclic, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return state in self._cyclic_states_indices
[docs] def is_recurrent_state(self, state: tstate) -> bool:
"""
The method verifies whether the given state is recurrent.
:param state: the target state.
:return: True if the state is recurrent, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return state in self._recurrent_states_indices
[docs] def is_transient_state(self, state: tstate) -> bool:
"""
The method verifies whether the given state is transient.
:param state: the target state.
:return: True if the state is transient, False otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return state in self._transient_states_indices
[docs] @alias('mfpt_between')
def mean_first_passage_times_between(self, states_target: tstates, states_origin: tstates) -> oarray:
"""
The method computes the mean first passage times between the given subsets of the state space.
| **Aliases:** mfpt_between
:param states_target: the subset of target states.
:param states_origin: the subset of origin states.
:return: the mean first passage times between the given subsets if the Markov chain is *irreducible*, None otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
states_target = validate_states(states_target, self._states, 'subset', True)
states_origin = validate_states(states_origin, self._states, 'subset', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if not self.is_irreducible:
return None
states_target = sorted(states_target)
states_origin = sorted(states_origin)
a = np.eye(self._size, dtype=float) - self._p
a[states_target, :] = 0.0
a[states_target, states_target] = 1.0
b = np.ones(self._size, dtype=float)
b[states_target] = 0.0
mfpt_to = npl.solve(a, b)
pi = self.pi[0]
pi_origin_states = pi[states_origin]
mu = pi_origin_states / np.sum(pi_origin_states)
mfpt_between = np.dot(mu, mfpt_to[states_origin])
if np.isscalar(mfpt_between):
mfpt_between = np.array([mfpt_between])
return mfpt_between
[docs] @alias('mfpt_to')
def mean_first_passage_times_to(self, states: ostates = None) -> tarray:
"""
The method computes the mean first passage times, for all the states, to the given set of states.
| **Aliases:** mfpt_to
:param states: the set of target states (if omitted, all the states are targeted).
:return: the mean first passage times of each state of the Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
if states is None:
states = self._states_indices.copy()
else:
states = validate_states(states, self._states, 'regular', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
states = sorted(states)
a = np.eye(self._size, dtype=float) - self._p
a[states, :] = 0.0
a[states, states] = 1.0
b = np.ones(self._size, dtype=float)
b[states] = 0.0
return npl.solve(a, b)
[docs] def mixing_time(self, initial_distribution: onumeric = None, jump: int = 1, cutoff_type: str = 'natural') -> oint:
"""
The method computes the mixing time of the Markov chain, given the initial distribution of the states.
:param initial_distribution: the initial distribution of the states (if omitted, the states are assumed to be uniformly distributed).
:param jump: the number of steps in each iteration (by default, 1).
:param cutoff_type: the type of cutoff to use (either natural or traditional; by default, natural).
:return: the mixing time if the Markov chain is *ergodic*, None otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
if initial_distribution is None:
initial_distribution = np.ones(self._size, dtype=float) / self._size
else:
initial_distribution = validate_vector(initial_distribution, 'stochastic', False, size=self._size)
jump = validate_integer(jump, lower_limit=(0, True))
cutoff_type = validate_enumerator(cutoff_type, ['natural', 'traditional'])
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if not self.is_ergodic:
return None
if cutoff_type == 'traditional':
cutoff = 0.25
else:
cutoff = 1.0 / (2.0 * np.exp(1.0))
mixing_time = 0
tvd = 1.0
d = initial_distribution.dot(self._p)
pi = self.pi[0]
while tvd > cutoff:
tvd = np.sum(np.abs(d - pi))
mixing_time += jump
d = d.dot(self._p)
return mixing_time
[docs] def closest_reversible(self, distribution: tnumeric, weighted: bool = False) -> omc:
"""
The method computes the closest reversible of the Markov chain.
| **Notes:** the algorithm is described in `Computing the nearest reversible Markov Chain (Nielsen & Weber, 2015) <http://doi.org/10.1002/nla.1967>`_.
:param distribution: the distribution of the states.
:param weighted: a boolean indicating whether to use a weighted Frobenius norm (by default, False).
:return: a Markov chain if the algorithm finds a solution, None otherwise.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if a weighted Frobenius norm is used and the distribution contains zero-valued probabilities.
"""
def jacobian(xj: tarray, hj: tarray, fj: tarray):
return np.dot(np.transpose(xj), hj) + fj
def objective(xo: tarray, ho: tarray, fo: tarray):
return (0.5 * npl.multi_dot([np.transpose(xo), ho, xo])) + np.dot(np.transpose(fo), xo)
try:
distribution = validate_vector(distribution, 'stochastic', False, size=self._size)
weighted = validate_boolean(weighted)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
non_zeros = np.count_nonzero(distribution)
zeros = len(distribution) - non_zeros
if weighted and (zeros > 0):
raise ValueError('The distribution contains zero-valued probabilities.')
m = int((((self._size - 1) * self._size) / 2) + (((zeros - 1) * zeros) / 2) + 1)
basis_vectors = []
for r in range(self._size - 1):
for s in range(r + 1, self._size):
if (distribution[r] == 0.0) and (distribution[s] == 0.0):
bv = np.eye(self._size, dtype=float)
bv[r, r] = 0.0
bv[r, s] = 1.0
basis_vectors.append(bv)
bv = np.eye(self._size, dtype=float)
bv[r, r] = 1.0
bv[r, s] = 0.0
bv[s, s] = 0.0
bv[s, r] = 1.0
basis_vectors.append(bv)
else:
bv = np.eye(self._size, dtype=float)
bv[r, r] = 1.0 - distribution[s]
bv[r, s] = distribution[s]
bv[s, s] = 1.0 - distribution[r]
bv[s, r] = distribution[r]
basis_vectors.append(bv)
basis_vectors.append(np.eye(self._size, dtype=float))
h = np.zeros((m, m), dtype=float)
f = np.zeros(m, dtype=float)
if weighted:
d = np.diag(distribution)
di = npl.inv(d)
for i in range(m):
bv_i = basis_vectors[i]
z = npl.multi_dot([d, bv_i, di])
f[i] = -2.0 * np.trace(np.dot(z, np.transpose(self._p)))
for j in range(m):
bv_j = basis_vectors[j]
tau = 2.0 * np.trace(np.dot(np.transpose(z), bv_j))
h[i, j] = tau
h[j, i] = tau
else:
for i in range(m):
bv_i = basis_vectors[i]
f[i] = -2.0 * np.trace(np.dot(np.transpose(bv_i), self._p))
for j in range(m):
bv_j = basis_vectors[j]
tau = 2.0 * np.trace(np.dot(np.transpose(bv_i), bv_j))
h[i, j] = tau
h[j, i] = tau
a = np.zeros((m + self._size - 1, m), dtype=float)
np.fill_diagonal(a, -1.0)
a[m - 1, m - 1] = 0.0
for i in range(self._size):
k = 0
for r in range(self._size - 1):
for s in range(r + 1, self._size):
if (distribution[s] == 0.0) and (distribution[r] == 0.0):
if r != i:
a[m + i - 1, k] = -1.0
else:
a[m + i - 1, k] = 0.0
k += 1
if s != i:
a[m + i - 1, k] = -1.0
else:
a[m + i - 1, k] = 0.0
elif s == i:
a[m + i - 1, k] = -1.0 + distribution[r]
elif r == i:
a[m + i - 1, k] = -1.0 + distribution[s]
else:
a[m + i - 1, k] = -1.0
k += 1
a[m + i - 1, m - 1] = -1.0
b = np.zeros(m + self._size - 1, dtype=float)
x0 = np.zeros(m, dtype=float)
constraints = (
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1.0},
{'type': 'ineq', 'fun': lambda x: b - np.dot(a, x), 'jac': lambda x: -a}
)
# noinspection PyTypeChecker
solution = spo.minimize(objective, x0, jac=jacobian, args=(h, f), constraints=constraints, method='SLSQP', options={'disp': False})
if not solution['success']:
return None
p = np.zeros((self._size, self._size), dtype=float)
solution = solution['x']
for i in range(m):
p += solution[i] * basis_vectors[i]
cr = MarkovChain(p, self._states)
if not cr.is_reversible:
return None
return cr
[docs] def predict(self, steps: int, initial_state: ostate = None, include_initial: bool = False, output_indices: bool = False, seed: oint = None) -> twalk:
"""
The method simulates the most probable outcome of a random walk of N steps.
| **Notes:** in case of probability tie, the subsequent state is chosen uniformly at random among all the equiprobable states.
:param steps: the number of steps.
:param initial_state: the initial state of the prediction (if omitted, it is chosen uniformly at random).
:param include_initial: a boolean indicating whether to include the initial state in the output sequence (by default, False).
:param output_indices: a boolean indicating whether to the output the state indices (by default, False).
:param seed: a seed to be used as RNG initializer for reproducibility purposes.
:return: the sequence of states produced by the simulation.
:raises ValidationError: if any input argument is not compliant.
"""
try:
rng = MarkovChain._create_rng(seed)
steps = validate_integer(steps, lower_limit=(0, True))
if initial_state is None:
initial_state = rng.randint(0, self._size)
else:
initial_state = validate_state(initial_state, self._states)
include_initial = validate_boolean(include_initial)
output_indices = validate_boolean(output_indices)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
prediction = list()
if include_initial:
prediction.append(initial_state)
current_state = initial_state
for i in range(steps):
d = self._p[current_state, :]
d_max = np.argwhere(d == np.max(d))
w = np.zeros(self._size, dtype=float)
w[d_max] = 1.0 / d_max.size
current_state = np.asscalar(rng.choice(self._size, size=1, p=w))
prediction.append(current_state)
if not output_indices:
prediction = [*map(self._states.__getitem__, prediction)]
return prediction
[docs] def prior_probabilities(self, hyperparameter: onumeric = None) -> tarray:
"""
The method computes the prior probabilities, in logarithmic form, of the Markov chain.
:param hyperparameter: the matrix for the a priori distribution (if omitted, a default value of 1 is assigned to each parameter).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
if hyperparameter is None:
hyperparameter = np.ones((self._size, self._size), dtype=float)
else:
hyperparameter = validate_hyperparameter(hyperparameter, self._size)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
lps = np.zeros(self._size, dtype=float)
for i in range(self._size):
lp = 0.0
for j in range(self._size):
hij = hyperparameter[i, j]
lp += (hij - 1.0) * np.log(self._p[i, j]) - lgamma(hij)
lps[i] = (lp + lgamma(np.sum(hyperparameter[i, :])))
return lps
[docs] def redistribute(self, steps: int, initial_status: ostatus = None, include_initial: bool = False, output_last: bool = True) -> tlist_array:
"""
The method simulates a redistribution of states of N steps.
:param steps: the number of steps.
:param initial_status: the initial state or the initial distribution of the states (if omitted, the states are assumed to be uniformly distributed).
:param include_initial: a boolean indicating whether to include the initial distribution in the output sequence (by default, False).
:param output_last: a boolean indicating whether to the output only the last distributions (by default, True).
:return: the sequence of redistributions produced by the simulation.
:raises ValidationError: if any input argument is not compliant.
"""
try:
steps = validate_integer(steps, lower_limit=(0, True))
if initial_status is None:
initial_status = np.ones(self._size, dtype=float) / self._size
else:
initial_status = validate_status(initial_status, self._states)
include_initial = validate_boolean(include_initial)
output_last = validate_boolean(output_last)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
distributions = np.zeros((steps, self._size), dtype=float)
for i in range(steps):
if i == 0:
distributions[i, :] = initial_status.dot(self._p)
else:
distributions[i, :] = distributions[i - 1, :].dot(self._p)
distributions[i, :] = distributions[i, :] / sum(distributions[i, :])
if output_last:
distributions = distributions[-1:, :]
if include_initial:
distributions = np.vstack((initial_status, distributions))
return [np.ravel(x) for x in np.split(distributions, distributions.shape[0])]
[docs] def sensitivity(self, state: tstate) -> oarray:
"""
The method computes the sensitivity matrix of the stationary distribution with respect to a given state.
:param state: the target state.
:return: the sensitivity matrix of the stationary distribution if the Markov chain is *irreducible*, None otherwise.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state = validate_state(state, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if not self.is_irreducible:
return None
lev = np.ones(self._size, dtype=float)
rev = self.pi[0]
a = np.transpose(self._p) - np.eye(self._size, dtype=float)
a = np.transpose(np.concatenate((a, [lev])))
b = np.zeros(self._size, dtype=float)
b[state] = 1.0
phi = npl.lstsq(a, b, rcond=-1)
phi = np.delete(phi[0], -1)
sensitivity = -np.outer(rev, phi) + (np.dot(phi, rev) * np.outer(rev, lev))
return sensitivity
[docs] def to_dictionary(self) -> tmc_dict:
"""
The method returns a dictionary representing the Markov chain.
:return: a dictionary.
"""
d = {}
for i in range(self.size):
for j in range(self.size):
d[(self._states[i], self._states[j])] = self._p[i, j]
return d
[docs] @alias('to_graph')
def to_directed_graph(self, multi: bool = True) -> tgraphs:
"""
The method returns a directed graph representing the Markov chain.
| **Aliases:** to_digraph
:param multi: a boolean indicating whether the graph is allowed to define multiple edges between two nodes (by default, True).
:return: a directed graph.
:raises ValidationError: if any input argument is not compliant.
"""
try:
multi = validate_boolean(multi)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if multi:
graph = nx.MultiDiGraph(self._p)
else:
graph = deepcopy(self._digraph)
graph = nx.relabel_nodes(graph, dict(zip(range(self._size), self._states)))
return graph
[docs] def to_file(self, file_path: str, file_type: str = 'json'):
"""
The method writes a Markov chain to the given file.
:param file_path: the location of the file in which the Markov chain must be written.
:param file_type: the type of file that defines the Markov chain (either json or text; by default, json).
:raises OSError: if the file cannot be written.
:raises ValidationError: if any input argument is not compliant.
"""
try:
file_path = validate_string(file_path)
file_type = validate_enumerator(file_type, ['json', 'text'])
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
d = self.to_dictionary()
if file_type == 'json':
if not file_path.lower().endswith('.json'):
raise ValidationError('The path must point to a valid json file.')
output = []
for it, ip in d.items():
output.append({'state_from': it[0], 'state_to': it[1], 'probability': ip})
with open(file_path, mode='w') as file:
dump(output, file)
else:
if not file_path.lower().endswith('.txt'):
raise ValidationError('The path must point to a valid text file.')
with open(file_path, mode='w') as file:
for it, ip in d.items():
file.write(f'{it[0]} {it[1]} {ip}\n')
[docs] @alias('to_lazy')
def to_lazy_chain(self, inertial_weights: tweights = 0.5) -> tmc:
"""
The method returns a lazy chain by adjusting the state inertia of the original process.
:param inertial_weights: the inertial weights to apply for the transformation (by default, 0.5).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
inertial_weights = validate_vector(inertial_weights, 'unconstrained', True, size=self._size)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
p_adjusted = ((1.0 - inertial_weights)[:, np.newaxis] * self._p) + (np.eye(self._size, dtype=float) * inertial_weights)
return MarkovChain(p_adjusted, self._states)
[docs] def to_subchain(self, states: tstates) -> tmc:
"""
The method returns a subchain containing all the given states plus all the states reachable from them.
:param states: the states to include in the subchain.
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
states = validate_states(states, self._states, 'subset', True)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
closure = self.adjacency_matrix.copy()
for i in range(self._size):
for j in range(self._size):
for x in range(self._size):
closure[j, x] = closure[j, x] or (closure[j, i] and closure[i, x])
for s in states:
for sc in np.ravel([np.where(closure[s, :] == 1)]):
if sc not in states:
states.append(sc)
states = sorted(states)
p = self._p.copy()
p = p[np.ix_(states, states)]
states = [*map(self._states.__getitem__, states)]
return MarkovChain(p, states)
[docs] def transition_probability(self, state_target: tstate, state_origin: tstate) -> float:
"""
The method computes the probability of a given state, conditioned on the process being at a given specific state.
:param state_target: the target state.
:param state_origin: the origin state.
:return: the transition probability of the target state.
:raises ValidationError: if any input argument is not compliant.
"""
try:
state_target = validate_state(state_target, self._states)
state_origin = validate_state(state_origin, self._states)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return self._p[state_origin, state_target]
[docs] def walk(self, steps: int, initial_state: ostate = None, final_state: ostate = None, include_initial: bool = False, output_indices: bool = False, seed: oint = None) -> twalk:
"""
The method simulates a random walk of N steps.
:param steps: the number of steps.
:param initial_state: the initial state of the walk (if omitted, it is chosen uniformly at random).
:param final_state: the final state of the walk (if specified, the simulation stops as soon as it is reached even if not all the steps have been performed).
:param include_initial: a boolean indicating whether to include the initial state in the output sequence (by default, False).
:param output_indices: a boolean indicating whether to the output the state indices (by default, False).
:param seed: a seed to be used as RNG initializer for reproducibility purposes.
:return: the sequence of states produced by the simulation.
:raises ValidationError: if any input argument is not compliant.
"""
try:
rng = MarkovChain._create_rng(seed)
steps = validate_integer(steps, lower_limit=(0, True))
if initial_state is None:
initial_state = rng.randint(0, self._size)
else:
initial_state = validate_state(initial_state, self._states)
include_initial = validate_boolean(include_initial)
if final_state is not None:
final_state = validate_state(final_state, self._states)
output_indices = validate_boolean(output_indices)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
walk = list()
if include_initial:
walk.append(initial_state)
current_state = initial_state
for i in range(steps):
w = self._p[current_state, :]
current_state = np.asscalar(rng.choice(self._size, size=1, p=w))
walk.append(current_state)
if current_state == final_state:
break
if not output_indices:
walk = [*map(self._states.__getitem__, walk)]
return walk
[docs] def walk_probability(self, walk: twalk) -> float:
"""
The method computes the probability of a given sequence of states.
:param walk: the sequence of states.
:return: the probability of the sequence of states.
:raises ValidationError: if any input argument is not compliant.
"""
try:
walk = validate_states(walk, self._states, 'walk', False)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
p = 0.0
for step in zip(walk[:-1], walk[1:]):
if self._p[step[0], step[1]] > 0:
p += np.log(self._p[step[0], step[1]])
else:
p = -np.inf
break
return np.exp(p)
@staticmethod
def _calculate_period(graph: nx.Graph) -> int:
g = 0
for sccs in nx.strongly_connected_components(graph):
sccs = list(sccs)
levels = dict((scc, None) for scc in sccs)
vertices = levels
scc = sccs[0]
levels[scc] = 0
current_level = [scc]
previous_level = 1
while current_level:
next_level = []
for u in current_level:
for v in graph[u]:
if v not in vertices:
continue
level = levels[v]
if level is not None:
g = gcd(g, previous_level - level)
if g == 1:
return 1
else:
next_level.append(v)
levels[v] = previous_level
current_level = next_level
previous_level += 1
return g
# noinspection PyProtectedMember
@staticmethod
def _create_rng(seed) -> npr.RandomState:
if seed is None:
return nprm._rand
if isinstance(seed, int):
return npr.RandomState(seed)
raise TypeError('The specified seed is not a valid RNG initializer.')
@staticmethod
def _gth_solve(p: tarray) -> tarray:
a = np.array(p, copy=True)
n = a.shape[0]
for i in range(n - 1):
scale = np.sum(a[i, i + 1:n])
if scale <= 0.0:
n = i + 1
break
a[i + 1:n, i] /= scale
a[i + 1:n, i + 1:n] += np.dot(a[i + 1:n, i:i + 1], a[i:i + 1, i + 1:n])
x = np.zeros(n, dtype=float)
x[n - 1] = 1.0
for i in range(n - 2, -1, -1):
x[i] = np.dot(x[i + 1:n], a[i + 1:n, i])
x /= np.sum(x)
return x
[docs] @staticmethod
def approximation(size: int, approximation_type: str, alpha: float, sigma: float, rho: float, k: ofloat = None) -> tmc_approx:
"""
The method approximates the Markov chain associated with the discretized version of the following first-order autoregressive process:
| :math:`y_t = (1 - \\rho) \\alpha + \\rho y_{t-1} + \\varepsilon_t`
| with :math:`\\varepsilon_t \\overset{i.i.d}{\\sim} \\mathcal{N}(0, \\sigma_{\\varepsilon}^{2})`
:param size: the size of the Markov chain.
:param approximation_type: the approximation type to use (one of adda-cooper, rouwenhorst, tauchen or tauchen-hussey).
:param alpha: the constant term :math:`\\alpha`, representing the unconditional mean of the process.
:param sigma: the standard deviation of the innovation term :math:`\\varepsilon`.
:param rho: the autocorrelation coefficient :math:`\\rho`, representing the persistence of the process across periods.
:param k:
- in the Tauchen approximation, the number of standard deviations to approximate out to (if omitted, the value is set to 3);
- in the Tauchen-Hussey approximation, the standard deviation used for the gaussian quadrature (if omitted, the value is set to an optimal default).
:return: a tuple whose first element is a Markov chain and whose second element is a vector of nodes.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the approximation type is neither Tauchen nor Tauchen-Hussey and *k* is not equal to None or if the gaussian quadrature fails to converge in the Tauchen-Hussey approach.
"""
def adda_cooper_integrand(aci_x, aci_sigma_z, aci_sigma, aci_rho, aci_alpha, z_j, z_jp1):
t1 = np.exp((-1.0 * (aci_x - aci_alpha)**2.0) / (2.0 * aci_sigma_z**2.0))
t2 = sps.norm.cdf((z_jp1 - (aci_alpha * (1.0 - aci_rho)) - (aci_rho * aci_x)) / aci_sigma)
t3 = sps.norm.cdf((z_j - (aci_alpha * (1.0 - aci_rho)) - (aci_rho * aci_x)) / aci_sigma)
return t1 * (t2 - t3)
def rouwenhorst_matrix(rm_size: int, rm_p: float, rm_q: float) -> tarray:
if rm_size == 2:
theta = np.array([[rm_p, 1 - rm_p], [1 - rm_q, rm_q]])
else:
t1 = np.zeros((rm_size, rm_size))
t2 = np.zeros((rm_size, rm_size))
t3 = np.zeros((rm_size, rm_size))
t4 = np.zeros((rm_size, rm_size))
theta_inner = rouwenhorst_matrix(rm_size - 1, rm_p, rm_q)
t1[:rm_size - 1, :rm_size - 1] = rm_p * theta_inner
t2[:rm_size - 1, 1:] = (1.0 - rm_p) * theta_inner
t3[1:, :-1] = (1.0 - rm_q) * theta_inner
t4[1:, 1:] = rm_q * theta_inner
theta = t1 + t2 + t3 + t4
theta[1:rm_size - 1, :] /= 2.0
return theta
try:
size = validate_transition_matrix_size(size)
approximation_type = validate_enumerator(approximation_type, ['adda-cooper', 'rouwenhorst', 'tauchen', 'tauchen-hussey'])
alpha = validate_float(alpha)
sigma = validate_float(sigma, lower_limit=(0.0, True))
rho = validate_float(rho, lower_limit=(-1.0, False), upper_limit=(1.0, False))
if approximation_type == 'tauchen':
if k is None:
k = 3.0
else:
k = validate_float(k, lower_limit=(1.0, False))
elif approximation_type == 'tauchen-hussey':
if k is None:
w = 0.5 + (rho / 4.0)
k = (w * sigma) + ((1 - w) * (sigma / np.sqrt(1.0 - rho ** 2.0)))
else:
k = validate_float(k, lower_limit=(0.0, True))
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if approximation_type == 'adda-cooper':
z_sigma = sigma / (1.0 - rho**2.00)**0.5
z = (z_sigma * sps.norm.ppf(np.arange(size + 1) / size)) + alpha
p = np.zeros((size, size), dtype=float)
for i in range(size):
for j in range(size):
iq = spi.quad(adda_cooper_integrand, z[i], z[i + 1], args=(z_sigma, sigma, rho, alpha, z[j], z[j + 1]))
p[i, j] = (size / np.sqrt(2.0 * np.pi * z_sigma**2.0)) * iq[0]
nodes = (size * z_sigma * (sps.norm.pdf((z[:-1] - alpha) / z_sigma) - sps.norm.pdf((z[1:] - alpha) / z_sigma))) + alpha
elif approximation_type == 'rouwenhorst':
p = (1.0 + rho) / 2.0
q = p
p = rouwenhorst_matrix(size, p, q)
y_std = np.sqrt(sigma**2.0 / (1.0 - rho**2.0))
psi = y_std * np.sqrt(size - 1)
nodes = np.linspace(-psi, psi, size) + (alpha / (1.0 - rho))
elif approximation_type == 'tauchen-hussey':
nodes = np.zeros(size, dtype=float)
weights = np.zeros(size, dtype=float)
pp = 0.0
z = 0.0
for i in range(int(np.fix((size + 1) / 2))):
if i == 0:
z = np.sqrt((2.0 * size) + 1.0) - (1.85575 * ((2.0 * size) + 1.0)**-0.16393)
elif i == 1:
z = z - ((1.140 * size**0.426) / z)
elif i == 2:
z = (1.86 * z) + (0.86 * nodes[0])
elif i == 3:
z = (1.91 * z) + (0.91 * nodes[1])
else:
z = (2.0 * z) + nodes[i - 2]
iterations = 0
while iterations < 100:
iterations += 1
p1 = 1.0 / np.pi**0.25
p2 = 0.0
for j in range(1, size + 1):
p3 = p2
p2 = p1
p1 = (z * np.sqrt(2.0 / j) * p2) - (np.sqrt((j - 1.0) / j) * p3)
pp = np.sqrt(2.0 * size) * p2
z1 = z
z = z1 - p1 / pp
if np.abs(z - z1) < 1e-14:
break
if iterations == 100:
raise ValueError('The gaussian quadrature failed to converge.')
nodes[i] = -z
nodes[size - i - 1] = z
weights[i] = 2.0 / pp**2.0
weights[size - i - 1] = weights[i]
nodes = (nodes * np.sqrt(2.0) * np.sqrt(2.0 * k ** 2.0)) + alpha
weights = weights / np.sqrt(np.pi)**2.0
p = np.zeros((size, size), dtype=float)
for i in range(size):
for j in range(size):
prime = ((1.0 - rho) * alpha) + (rho * nodes[i])
p[i, j] = (weights[j] * sps.norm.pdf(nodes[j], prime, sigma) / sps.norm.pdf(nodes[j], alpha, k))
for i in range(size):
p[i, :] /= np.sum(p[i, :])
else:
y_std = np.sqrt(sigma**2.0 / (1.0 - rho**2.0))
x_max = y_std * k
x_min = -x_max
x = np.linspace(x_min, x_max, size)
step = 0.5 * ((x_max - x_min) / (size - 1))
p = np.zeros((size, size), dtype=float)
for i in range(size):
p[i, 0] = sps.norm.cdf((x[0] - (rho * x[i]) + step) / sigma)
p[i, size - 1] = 1.0 - sps.norm.cdf((x[size - 1] - (rho * x[i]) - step) / sigma)
for j in range(1, size - 1):
z = x[j] - (rho * x[i])
p[i, j] = sps.norm.cdf((z + step) / sigma) - sps.norm.cdf((z - step) / sigma)
nodes = x + (alpha / (1.0 - rho))
return MarkovChain(p), nodes
[docs] @staticmethod
def birth_death(p: tarray, q: tarray, states: olist_str = None) -> tmc:
"""
The method generates a birth-death Markov chain of given size and from given probabilities.
:param q: the creation probabilities.
:param p: the annihilation probabilities.
:param states: the name of each state (if omitted, an increasing sequence of integers starting at 1).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if *q* and *p* have a different size or if the vector resulting from the sum of *q* and *p* contains any value greater than 1.
"""
try:
p = validate_vector(p, 'creation', False)
q = validate_vector(q, 'annihilation', False)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
if p.shape[0] != q.shape[0]:
raise ValueError(f'The assets vector and the liabilities vector must have the same size.')
if not np.all(q + p <= 1.0):
raise ValueError('The sums of annihilation and creation probabilities must be less than or equal to 1.')
n = {p.shape[0], q.shape[0]}.pop()
try:
if states is None:
states = [str(i) for i in range(1, n + 1)]
else:
states = validate_state_names(states, n)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
r = 1.0 - q - p
p = np.diag(r, k=0) + np.diag(p[0:-1], k=1) + np.diag(q[1:], k=-1)
return MarkovChain(p, states)
[docs] @staticmethod
def fit_function(f: ttfunc, possible_states: tlist_str, quadrature_type: str = 'newton-cotes', quadrature_interval: ointerval = None) -> tmc:
"""
The method fits a Markov chain using the given transition function and with the given quadrature type.
:param f: the transition function of the process.
:param possible_states: the possible states of the process.
:param quadrature_type: the quadrature type to use for the computation of nodes and weights (one of gauss-chebyshev, gauss-legendre, neiderreiter, newton-cotes, simpson-rule or trapezoid-rule; by default, newton-cotes).
:param quadrature_interval: the quadrature interval to use for the computation of nodes and weights (by default, the interval [0, 1]).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the Gauss-Legendre quadrature fails to converge or if the Simpson rule quadrature is attempted on an even number of possible states.
"""
try:
f = validate_transition_function(f)
possible_states = validate_state_names(possible_states)
quadrature_type = validate_enumerator(quadrature_type, ['gauss-chebyshev', 'gauss-legendre', 'neiderreiter', 'newton-cotes', 'simpson-rule', 'trapezoid-rule'])
if quadrature_interval is None:
quadrature_interval = (0.0, 1.0)
else:
quadrature_interval = validate_interval(quadrature_interval)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
size = len(possible_states)
a = quadrature_interval[0]
b = quadrature_interval[1]
if quadrature_type == 'gauss-chebyshev':
t1 = np.arange(size) + 0.5
t2 = np.arange(0.0, size, 2.0)
t3 = np.concatenate((np.array([1.0]), -2.0 / (np.arange(1.0, size - 1.0, 2) * np.arange(3.0, size + 1.0, 2))))
nodes = ((b + a) / 2.0) - ((b - a) / 2.0) * np.cos((np.pi / size) * t1)
weights = ((b - a) / size) * np.cos((np.pi / size) * np.outer(t1, t2)) @ t3
elif quadrature_type == 'gauss-legendre':
nodes = np.zeros(size, dtype=float)
weights = np.zeros(size, dtype=float)
iterations = 0
i = np.arange(int(np.fix((size + 1.0) / 2.0)))
pp = 0.0
z = np.cos(np.pi * ((i + 1.0) - 0.25) / (size + 0.5))
while iterations < 100:
iterations += 1
p1 = np.ones_like(z, dtype=float)
p2 = np.zeros_like(z, dtype=float)
for j in range(1, size + 1):
p3 = p2
p2 = p1
p1 = ((((2.0 * j) - 1.0) * z * p2) - ((j - 1) * p3)) / j
pp = size * (((z * p1) - p2) / (z**2.0 - 1.0))
z1 = np.copy(z)
z = z1 - (p1 / pp)
if np.allclose(abs(z - z1), 0.0):
break
if iterations == 100:
raise ValueError('The Gauss-Legendre quadrature failed to converge.')
xl = 0.5 * (b - a)
xm = 0.5 * (b + a)
nodes[i] = xm - (xl * z)
nodes[-i - 1] = xm + (xl * z)
weights[i] = (2.0 * xl) / ((1.0 - z**2.0) * pp**2.0)
weights[-i - 1] = weights[i]
elif quadrature_type == 'neiderreiter':
r = b - a
nodes = np.arange(1.0, size + 1.0) * 2.0**0.5
nodes = nodes - np.fix(nodes)
nodes = a + (nodes * r)
weights = (r / size) * np.ones(size, dtype=float)
elif quadrature_type == 'simpson-rule':
if (size % 2) == 0:
raise ValueError('The Simpson quadrature requires an odd number of possible states.')
nodes = np.linspace(a, b, size)
weights = np.kron(np.ones((size + 1) // 2, dtype=float), np.array([2.0, 4.0]))
weights = weights[:size]
weights[0] = weights[-1] = 1
weights = ((nodes[1] - nodes[0]) / 3.0) * weights
elif quadrature_type == 'trapezoid-rule':
nodes = np.linspace(a, b, size)
weights = (nodes[1] - nodes[0]) * np.ones(size)
weights[0] *= 0.5
weights[-1] *= 0.5
else:
bandwidth = (b - a) / size
nodes = (np.arange(size) + 0.5) * bandwidth
weights = np.repeat(bandwidth, size)
p = np.zeros((size, size), dtype=float)
for i in range(size):
for j in range(size):
p[i, j] = f(nodes[i], nodes[j]) * weights[j]
for i in range(p.shape[0]):
p[i, :] /= np.sum(p[i, :])
return MarkovChain(p, possible_states)
[docs] @staticmethod
def fit_standard(possible_states: tlist_str, walk: twalk, fitting_type: str, k: tany = None) -> tmc:
"""
The method fits a Markov chain using either the maximum a posteriori approach (MAP) or the maximum likelihood approach (MLE).
:param possible_states: the possible states of the process.
:param walk: the observed sequence of states.
:param fitting_type: the type of fitting to use (either map or mle).
:param k:
- in the maximum a posteriori approach, the matrix for the a priori distribution (if omitted, a default value of 1 is assigned to each parameter);
- in the maximum likelihood approach, a boolean indicating whether to apply a Laplace smoothing to compensate for the unseen transition combinations (if omitted, the value is set to False).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
possible_states = validate_state_names(possible_states)
size = len(possible_states)
walk = validate_states(walk, possible_states, 'walk', False)
fitting_type = validate_enumerator(fitting_type, ['map', 'mle'])
if fitting_type == 'map':
if k is None:
k = np.ones((size, size), dtype=float)
else:
k = validate_hyperparameter(k, size)
else:
if k is None:
k = False
else:
k = validate_boolean(k)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
p = np.zeros((size, size), dtype=float)
if fitting_type == 'map':
frequencies = np.zeros((size, size), dtype=float)
for step in zip(walk[:-1], walk[1:]):
frequencies[step[0], step[1]] += 1.0
for i in range(size):
row_total = np.sum(frequencies[i, :]) + np.sum(k[i, :])
for j in range(size):
cell_total = frequencies[i, j] + k[i, j]
if row_total == size:
p[i, j] = 1.0 / size
else:
p[i, j] = (cell_total - 1.0) / (row_total - size)
else:
p = np.zeros((size, size), dtype=int)
for step in zip(walk[:-1], walk[1:]):
p[step[0], step[1]] += 1
if k:
p = p.astype(float)
p += 0.001
else:
p[np.where(~p.any(axis=1)), :] = np.ones(size, dtype=float)
p = p.astype(float)
p = p / np.sum(p, axis=1, keepdims=True)
return MarkovChain(p, possible_states)
[docs] @staticmethod
def from_dictionary(d: tmc_dict_flex) -> tmc:
"""
The method generates a Markov chain from the given dictionary, whose keys represent state pairs and whose values represent transition probabilities.
:param d: the dictionary to transform into the transition matrix.
:return: a Markov chain.
:raises ValueError: if the transition matrix defined by the dictionary is not valid.
:raises ValidationError: if any input argument is not compliant.
"""
try:
d = validate_dictionary(d)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
states = sorted(list(set(sum(d.keys(), ()))))
size = len(states)
if size < 2:
raise ValueError('The size of the transition matrix defined by the dictionary must be greater than or equal to 2.')
p = np.zeros((size, size), dtype=float)
for it, ip in d.items():
p[states.index(it[0]), states.index(it[1])] = ip
if not np.allclose(np.sum(p, axis=1), np.ones(size, dtype=float)):
raise ValueError('The rows of the transition matrix defined by the dictionary must sum to 1.')
return MarkovChain(p, states)
[docs] @staticmethod
def from_file(file_path: str, file_type: str = 'json') -> tmc:
"""
The method reads a Markov chain from the given file.
| In the *json format*, data must be structured as an array of objects with the following properties:
| *state_from* (string)
| *state_to* (string)
| *probability* (float or int)
|
| In the *text format*, every line of the file must have the following format:
| *<state_from> <state_to> <probability>*
:param file_path: the location of the file that defines the Markov chain.
:param file_type: the type of file that defines the Markov chain (either json or text; by default, json).
:return: a Markov chain.
:raises FileNotFoundError: if the file does not exist.
:raises OSError: if the file cannot be read or is empty.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the file contains invalid data.
"""
try:
file_path = validate_string(file_path)
file_type = validate_enumerator(file_type, ['json', 'text'])
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
d = {}
if file_type == 'json':
if not file_path.lower().endswith('.json'):
raise ValidationError('The path must point to a valid json file.')
with open(file_path, mode='r') as file:
file.seek(0)
if not file.read(1):
raise OSError('The file is empty.')
else:
file.seek(0)
data = load(file)
if not isinstance(data, list):
raise ValueError('The file is malformed.')
for obj in data:
if not isinstance(obj, dict):
raise ValueError('The file contains invalid entries.')
if sorted(list(set(obj.keys()))) != ['probability', 'state_from', 'state_to']:
raise ValueError('The file contains invalid lines.')
state_from = obj['state_from']
state_to = obj['state_to']
probability = obj['probability']
if not isinstance(state_from, str) or not isinstance(state_to, str) or not isinstance(probability, (float, int)):
raise ValueError('The file contains invalid lines.')
d[(state_from, state_to)] = float(probability)
else:
if not file_path.lower().endswith('.txt'):
raise ValidationError('The path must point to a valid text file.')
with open(file_path, mode='r') as file:
file.seek(0)
if not file.read(1):
raise OSError('The file is empty.')
else:
file.seek(0)
for line in file:
if not line.strip():
raise ValueError('The file contains invalid lines.')
ls = line.split()
if len(ls) != 3:
raise ValueError('The file contains invalid lines.')
try:
ls2 = float(ls[2])
except Exception:
raise ValueError('The file contains invalid lines.')
d[(ls[0], ls[1])] = ls2
states = sorted(list(set(sum(d.keys(), ()))))
size = len(states)
if size < 2:
raise ValueError('The size of the transition matrix defined by the file must be greater than or equal to 2.')
p = np.zeros((size, size), dtype=float)
for it, ip in d.items():
p[states.index(it[0]), states.index(it[1])] = ip
if not np.allclose(np.sum(p, axis=1), np.ones(size, dtype=float)):
raise ValueError('The rows of the transition matrix defined by the file must sum to 1.')
return MarkovChain(p, states)
[docs] @staticmethod
def from_matrix(m: tnumeric, states: olist_str = None) -> tmc:
"""
The method generates a Markov chain with the given state names, whose transition matrix is obtained through the normalization of the given matrix.
:param m: the matrix to transform into the transition matrix.
:param states: the name of each state (if omitted, an increasing sequence of integers starting at 1).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
m = validate_matrix(m)
if states is None:
states = [str(i) for i in range(1, m.shape[0] + 1)]
else:
states = validate_state_names(states, m.shape[0])
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
m = np.interp(m, (np.min(m), np.max(m)), (0.0, 1.0))
m = m / np.sum(m, axis=1, keepdims=True)
return MarkovChain(m, states)
[docs] @staticmethod
def identity(size: int, states: olist_str = None) -> tmc:
"""
The method generates a Markov chain of given size based on an identity transition matrix.
:param size: the size of the Markov chain.
:param states: the name of each state (if omitted, an increasing sequence of integers starting at 1).
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
"""
try:
size = validate_transition_matrix_size(size)
if states is None:
states = [str(i) for i in range(1, size + 1)]
else:
states = validate_state_names(states, size)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
return MarkovChain(np.eye(size, dtype=float), states)
[docs] @staticmethod
def random(size: int, states: olist_str = None, zeros: int = 0, mask: onumeric = None, seed: oint = None) -> tmc:
"""
The method generates a Markov chain of given size with random transition probabilities.
:param size: the size of the Markov chain.
:param states: the name of each state (if omitted, an increasing sequence of integers starting at 1).
:param zeros: the number of zero-valued transition probabilities (by default, 0).
:param mask: a matrix representing the locations and values of fixed transition probabilities.
:param seed: a seed to be used as RNG initializer for reproducibility purposes.
:return: a Markov chain.
:raises ValidationError: if any input argument is not compliant.
:raises ValueError: if the number of zero-valued transition probabilities exceeds the maximum threshold.
"""
try:
rng = MarkovChain._create_rng(seed)
size = validate_transition_matrix_size(size)
if states is None:
states = [str(i) for i in range(1, size + 1)]
else:
states = validate_state_names(states, size)
zeros = validate_integer(zeros, lower_limit=(0, False))
if mask is None:
mask = np.full((size, size), np.nan, dtype=float)
else:
mask = validate_mask(mask, size)
except Exception as e:
argument = ''.join(trace()[0][4]).split('=', 1)[0].strip()
raise ValidationError(str(e).replace('@arg@', argument)) from None
full_rows = np.isclose(np.nansum(mask, axis=1, dtype=float), 1.0)
mask_full = np.transpose(np.array([full_rows, ] * size))
mask[np.isnan(mask) & mask_full] = 0.0
mask_unassigned = np.isnan(mask)
zeros_required = np.asscalar(np.sum(mask_unassigned) - np.sum(~full_rows))
if zeros > zeros_required:
raise ValueError(f'The number of zero-valued transition probabilities exceeds the maximum threshold of {zeros_required:d}.')
n = np.arange(size)
for i in n:
if not full_rows[i]:
row = mask_unassigned[i, :]
columns = np.flatnonzero(row)
j = columns[rng.randint(0, np.asscalar(np.sum(row)))]
mask[i, j] = np.inf
mask_unassigned = np.isnan(mask)
indices_unassigned = np.flatnonzero(mask_unassigned)
r = rng.permutation(zeros_required)
indices_zero = indices_unassigned[r[0:zeros]]
indices_rows, indices_columns = np.unravel_index(indices_zero, (size, size))
mask[indices_rows, indices_columns] = 0.0
mask[np.isinf(mask)] = np.nan
p = mask.copy()
p_unassigned = np.isnan(mask)
p[p_unassigned] = np.ravel(rng.rand(1, np.asscalar(np.sum(p_unassigned, dtype=int))))
for i in n:
assigned_columns = np.isnan(mask[i, :])
s = np.sum(p[i, assigned_columns])
if s > 0.0:
si = np.sum(p[i, ~assigned_columns])
p[i, assigned_columns] = p[i, assigned_columns] * ((1.0 - si) / s)
return MarkovChain(p, states)