sofic 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Bayesian nonparametric HMM inference: the (sticky) HDP-HMM.
|
|
2
|
+
|
|
3
|
+
The hierarchical Dirichlet process HMM :cite:`Teh2006` and its sticky variant
|
|
4
|
+
:cite:`Fox2011` place a nonparametric prior over the number of hidden states, so
|
|
5
|
+
the state count is *inferred* rather than fixed. This module implements the
|
|
6
|
+
**weak-limit blocked Gibbs sampler** :cite:`Fox2011`: the countably-infinite HDP
|
|
7
|
+
prior is approximated by a symmetric Dirichlet over ``max_states`` components,
|
|
8
|
+
and each sweep
|
|
9
|
+
|
|
10
|
+
1. samples the whole hidden-state sequence with forward-filter/backward-sample
|
|
11
|
+
(FFBS) given the current parameters,
|
|
12
|
+
2. draws conjugate Dirichlet transition, initial, and (categorical /
|
|
13
|
+
Dirichlet-multinomial) emission rows given the state sequence, and
|
|
14
|
+
3. updates the shared top-level weights ``beta`` from Antoniak table counts,
|
|
15
|
+
with the sticky self-transition override of :cite:`Fox2011`.
|
|
16
|
+
|
|
17
|
+
Emissions are **categorical (discrete)** throughout -- the Gaussian-emission
|
|
18
|
+
variant is deliberately excluded. Retained posterior samples are returned as
|
|
19
|
+
:class:`~sofic.generators.moore.MooreHMM` generators restricted to the states
|
|
20
|
+
occupied in that sweep, together with a posterior over the number of occupied
|
|
21
|
+
states.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Hashable, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from sofic.generators.moore import MooreHMM
|
|
33
|
+
from sofic.inference.bayesian.counts import BayesianInferenceError
|
|
34
|
+
|
|
35
|
+
__all__ = ["HDPHMMPosterior", "infer_hdp_hmm"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class HDPHMMPosterior:
|
|
40
|
+
"""Posterior samples from the weak-limit (sticky) HDP-HMM sampler.
|
|
41
|
+
|
|
42
|
+
Attributes
|
|
43
|
+
----------
|
|
44
|
+
samples
|
|
45
|
+
Retained posterior draws as :class:`~sofic.generators.moore.MooreHMM`
|
|
46
|
+
generators, each restricted to the states occupied in its sweep.
|
|
47
|
+
state_counts
|
|
48
|
+
Number of occupied states in each retained draw (``len == len(samples)``).
|
|
49
|
+
log_likelihoods
|
|
50
|
+
Data log-likelihood (natural log) of each retained draw.
|
|
51
|
+
alphabet
|
|
52
|
+
Sorted observation alphabet used by the sampler.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
samples: list[MooreHMM] = field(default_factory=list)
|
|
56
|
+
state_counts: list[int] = field(default_factory=list)
|
|
57
|
+
log_likelihoods: list[float] = field(default_factory=list)
|
|
58
|
+
alphabet: tuple[Any, ...] = ()
|
|
59
|
+
|
|
60
|
+
def state_count_posterior(self) -> dict[int, float]:
|
|
61
|
+
"""Return the posterior distribution over the number of occupied states."""
|
|
62
|
+
if not self.state_counts:
|
|
63
|
+
return {}
|
|
64
|
+
counts = np.asarray(self.state_counts)
|
|
65
|
+
values, freqs = np.unique(counts, return_counts=True)
|
|
66
|
+
total = float(freqs.sum())
|
|
67
|
+
return {int(value): float(freq) / total for value, freq in zip(values, freqs, strict=True)}
|
|
68
|
+
|
|
69
|
+
def map_state_count(self) -> int:
|
|
70
|
+
"""Return the posterior modal number of occupied states."""
|
|
71
|
+
posterior = self.state_count_posterior()
|
|
72
|
+
if not posterior:
|
|
73
|
+
raise BayesianInferenceError("no retained samples")
|
|
74
|
+
return max(posterior, key=posterior.get)
|
|
75
|
+
|
|
76
|
+
def best_sample(self) -> MooreHMM:
|
|
77
|
+
"""Return the retained draw with the highest data log-likelihood."""
|
|
78
|
+
if not self.samples:
|
|
79
|
+
raise BayesianInferenceError("no retained samples")
|
|
80
|
+
return self.samples[int(np.argmax(self.log_likelihoods))]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normalize_sequences(sequences: Sequence[Any]) -> list[list[Any]]:
|
|
84
|
+
if sequences is None:
|
|
85
|
+
raise BayesianInferenceError("sequences is required")
|
|
86
|
+
if isinstance(sequences, (str, bytes)):
|
|
87
|
+
raise BayesianInferenceError("pass a sequence of observations, not a string")
|
|
88
|
+
first = next(iter(sequences), None)
|
|
89
|
+
if first is None:
|
|
90
|
+
raise BayesianInferenceError("at least one non-empty sequence is required")
|
|
91
|
+
if not isinstance(first, (list, tuple)):
|
|
92
|
+
sequences = [sequences] # a single flat observation sequence
|
|
93
|
+
out = [list(seq) for seq in sequences if len(seq) > 0]
|
|
94
|
+
if not out:
|
|
95
|
+
raise BayesianInferenceError("at least one non-empty sequence is required")
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _collect_alphabet(sequences: Sequence[Sequence[Any]]) -> tuple[Any, ...]:
|
|
100
|
+
symbols: set[Any] = set()
|
|
101
|
+
for seq in sequences:
|
|
102
|
+
symbols.update(seq)
|
|
103
|
+
return tuple(sorted(symbols, key=repr))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ffbs(
|
|
107
|
+
obs_idx: np.ndarray,
|
|
108
|
+
log_pi0: np.ndarray,
|
|
109
|
+
trans: np.ndarray,
|
|
110
|
+
emit: np.ndarray,
|
|
111
|
+
rng: np.random.Generator,
|
|
112
|
+
) -> tuple[np.ndarray, float]:
|
|
113
|
+
"""Forward-filter backward-sample one sequence; return states and log-likelihood."""
|
|
114
|
+
n_states = trans.shape[0]
|
|
115
|
+
length = obs_idx.shape[0]
|
|
116
|
+
alpha = np.empty((length, n_states))
|
|
117
|
+
loglik = 0.0
|
|
118
|
+
|
|
119
|
+
weights = np.exp(log_pi0) * emit[:, obs_idx[0]]
|
|
120
|
+
scale = weights.sum()
|
|
121
|
+
if scale <= 0.0:
|
|
122
|
+
weights = emit[:, obs_idx[0]].copy()
|
|
123
|
+
scale = weights.sum()
|
|
124
|
+
alpha[0] = weights / scale
|
|
125
|
+
loglik += np.log(scale)
|
|
126
|
+
|
|
127
|
+
for t in range(1, length):
|
|
128
|
+
predicted = alpha[t - 1] @ trans
|
|
129
|
+
weights = predicted * emit[:, obs_idx[t]]
|
|
130
|
+
scale = weights.sum()
|
|
131
|
+
if scale <= 0.0:
|
|
132
|
+
weights = emit[:, obs_idx[t]].copy()
|
|
133
|
+
scale = weights.sum()
|
|
134
|
+
alpha[t] = weights / scale
|
|
135
|
+
loglik += np.log(scale)
|
|
136
|
+
|
|
137
|
+
states = np.empty(length, dtype=int)
|
|
138
|
+
states[length - 1] = rng.choice(n_states, p=alpha[length - 1])
|
|
139
|
+
for t in range(length - 2, -1, -1):
|
|
140
|
+
probs = alpha[t] * trans[:, states[t + 1]]
|
|
141
|
+
total = probs.sum()
|
|
142
|
+
probs = probs / total if total > 0 else np.full(n_states, 1.0 / n_states)
|
|
143
|
+
states[t] = rng.choice(n_states, p=probs)
|
|
144
|
+
return states, float(loglik)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sample_dirichlet_rows(alpha_rows: np.ndarray, rng: np.random.Generator) -> np.ndarray:
|
|
148
|
+
"""Draw one Dirichlet vector per row (rows with zero mass fall back to uniform)."""
|
|
149
|
+
out = np.empty_like(alpha_rows, dtype=float)
|
|
150
|
+
for i, row in enumerate(alpha_rows):
|
|
151
|
+
if row.sum() <= 0:
|
|
152
|
+
out[i] = np.full(row.shape[0], 1.0 / row.shape[0])
|
|
153
|
+
else:
|
|
154
|
+
out[i] = rng.dirichlet(row)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _antoniak_tables(customers: int, mass: float, rng: np.random.Generator) -> int:
|
|
159
|
+
"""Sample the number of occupied CRP tables for ``customers`` at concentration ``mass``."""
|
|
160
|
+
if customers <= 0 or mass <= 0.0:
|
|
161
|
+
return 0
|
|
162
|
+
indices = np.arange(customers)
|
|
163
|
+
probs = mass / (mass + indices)
|
|
164
|
+
return int((rng.random(customers) < probs).sum())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def infer_hdp_hmm(
|
|
168
|
+
sequences: Sequence[Any],
|
|
169
|
+
*,
|
|
170
|
+
max_states: int = 20,
|
|
171
|
+
alpha: float = 1.0,
|
|
172
|
+
gamma: float = 1.0,
|
|
173
|
+
kappa: float = 0.0,
|
|
174
|
+
eta: float = 1.0,
|
|
175
|
+
iterations: int = 300,
|
|
176
|
+
burn_in: int = 150,
|
|
177
|
+
thin: int = 5,
|
|
178
|
+
rng: np.random.Generator | int | None = None,
|
|
179
|
+
) -> HDPHMMPosterior:
|
|
180
|
+
"""Infer a (sticky) HDP-HMM from discrete sequences by weak-limit blocked Gibbs.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
sequences
|
|
185
|
+
Either a single observation sequence (list/tuple of symbols) or a
|
|
186
|
+
collection of such sequences.
|
|
187
|
+
max_states
|
|
188
|
+
Weak-limit truncation ``L`` -- an upper bound on the number of states.
|
|
189
|
+
The *occupied* count is inferred and is typically far smaller.
|
|
190
|
+
alpha
|
|
191
|
+
Second-level (per-state transition) DP concentration.
|
|
192
|
+
gamma
|
|
193
|
+
Top-level DP concentration governing the shared weights ``beta``.
|
|
194
|
+
kappa
|
|
195
|
+
Sticky self-transition mass :cite:`Fox2011`. ``0`` recovers the plain
|
|
196
|
+
HDP-HMM; positive values bias toward state persistence.
|
|
197
|
+
eta
|
|
198
|
+
Symmetric Dirichlet concentration of the categorical emission prior.
|
|
199
|
+
iterations, burn_in, thin
|
|
200
|
+
Total Gibbs sweeps, discarded warm-up sweeps, and retention stride.
|
|
201
|
+
rng
|
|
202
|
+
``numpy`` generator or seed.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
HDPHMMPosterior
|
|
207
|
+
Retained :class:`~sofic.generators.moore.MooreHMM` draws and a posterior
|
|
208
|
+
over the number of occupied states.
|
|
209
|
+
"""
|
|
210
|
+
generator = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
|
|
211
|
+
|
|
212
|
+
seqs = _normalize_sequences(sequences)
|
|
213
|
+
alphabet = _collect_alphabet(seqs)
|
|
214
|
+
if not alphabet:
|
|
215
|
+
raise BayesianInferenceError("sequences contain no observations")
|
|
216
|
+
if max_states < 1:
|
|
217
|
+
raise BayesianInferenceError("max_states must be >= 1")
|
|
218
|
+
for name, value in (("alpha", alpha), ("gamma", gamma), ("eta", eta)):
|
|
219
|
+
if value <= 0.0:
|
|
220
|
+
raise BayesianInferenceError(f"{name} must be positive")
|
|
221
|
+
if kappa < 0.0:
|
|
222
|
+
raise BayesianInferenceError("kappa must be non-negative")
|
|
223
|
+
if burn_in >= iterations:
|
|
224
|
+
raise BayesianInferenceError("burn_in must be smaller than iterations")
|
|
225
|
+
|
|
226
|
+
symbol_index = {symbol: i for i, symbol in enumerate(alphabet)}
|
|
227
|
+
obs = [np.array([symbol_index[o] for o in seq], dtype=int) for seq in seqs]
|
|
228
|
+
n_obs = len(alphabet)
|
|
229
|
+
length = max_states
|
|
230
|
+
|
|
231
|
+
# Warm start: label each observation by its symbol so state k initially emits
|
|
232
|
+
# symbol k. Random (near-uniform) initialization mixes very poorly for
|
|
233
|
+
# discrete emissions -- the symmetric prior leaves the state labels
|
|
234
|
+
# unidentified for many sweeps -- whereas a symbol-based labeling immediately
|
|
235
|
+
# breaks that symmetry with near-deterministic emissions.
|
|
236
|
+
beta = np.full(length, 1.0 / length)
|
|
237
|
+
rho = kappa / (alpha + kappa) if (alpha + kappa) > 0 else 0.0
|
|
238
|
+
init_trans = np.zeros((length, length))
|
|
239
|
+
init_emit = np.zeros((length, n_obs))
|
|
240
|
+
init_first = np.zeros(length)
|
|
241
|
+
for obs_idx in obs:
|
|
242
|
+
warm = obs_idx % length
|
|
243
|
+
init_first[warm[0]] += 1
|
|
244
|
+
for o_i, s in zip(obs_idx, warm, strict=True):
|
|
245
|
+
init_emit[s, o_i] += 1
|
|
246
|
+
for a, b in zip(warm[:-1], warm[1:], strict=True):
|
|
247
|
+
init_trans[a, b] += 1
|
|
248
|
+
trans = _sample_dirichlet_rows(alpha * beta[None, :] + kappa * np.eye(length) + init_trans, generator)
|
|
249
|
+
emit = _sample_dirichlet_rows(eta + init_emit, generator)
|
|
250
|
+
log_pi0 = np.log(np.clip(_sample_dirichlet_rows((alpha * beta + init_first)[None, :], generator)[0], 1e-300, None))
|
|
251
|
+
|
|
252
|
+
posterior = HDPHMMPosterior(alphabet=alphabet)
|
|
253
|
+
|
|
254
|
+
for sweep in range(iterations):
|
|
255
|
+
trans_counts = np.zeros((length, length))
|
|
256
|
+
init_counts = np.zeros(length)
|
|
257
|
+
emit_counts = np.zeros((length, n_obs))
|
|
258
|
+
sweep_states: list[np.ndarray] = []
|
|
259
|
+
total_loglik = 0.0
|
|
260
|
+
|
|
261
|
+
for obs_idx in obs:
|
|
262
|
+
states, loglik = _ffbs(obs_idx, log_pi0, trans, emit, generator)
|
|
263
|
+
sweep_states.append(states)
|
|
264
|
+
total_loglik += loglik
|
|
265
|
+
init_counts[states[0]] += 1
|
|
266
|
+
for o_i, s in zip(obs_idx, states, strict=True):
|
|
267
|
+
emit_counts[s, o_i] += 1
|
|
268
|
+
for a, b in zip(states[:-1], states[1:], strict=True):
|
|
269
|
+
trans_counts[a, b] += 1
|
|
270
|
+
|
|
271
|
+
sticky = kappa * np.eye(length)
|
|
272
|
+
trans = _sample_dirichlet_rows(alpha * beta[None, :] + sticky + trans_counts, generator)
|
|
273
|
+
emit = _sample_dirichlet_rows(eta + emit_counts, generator)
|
|
274
|
+
pi0 = _sample_dirichlet_rows((alpha * beta + init_counts)[None, :], generator)[0]
|
|
275
|
+
log_pi0 = np.log(np.clip(pi0, 1e-300, None))
|
|
276
|
+
|
|
277
|
+
# Antoniak table counts -> top-level weights beta (with sticky override).
|
|
278
|
+
bar_m = np.zeros(length)
|
|
279
|
+
rows = list(trans_counts) + [init_counts]
|
|
280
|
+
for j, row in enumerate(rows):
|
|
281
|
+
for k in range(length):
|
|
282
|
+
mass = alpha * beta[k] + (kappa if j == k else 0.0)
|
|
283
|
+
tables = _antoniak_tables(int(row[k]), mass, generator)
|
|
284
|
+
if j == k and kappa > 0.0 and tables > 0:
|
|
285
|
+
denom = rho + beta[k] * (1.0 - rho)
|
|
286
|
+
override_p = rho / denom if denom > 0 else 0.0
|
|
287
|
+
overrides = int(generator.binomial(tables, min(max(override_p, 0.0), 1.0)))
|
|
288
|
+
tables -= overrides
|
|
289
|
+
bar_m[k] += tables
|
|
290
|
+
beta = generator.dirichlet(gamma / length + bar_m)
|
|
291
|
+
|
|
292
|
+
if sweep >= burn_in and (sweep - burn_in) % thin == 0:
|
|
293
|
+
machine = _build_moore(sweep_states, trans, emit, pi0, alphabet)
|
|
294
|
+
posterior.samples.append(machine)
|
|
295
|
+
posterior.state_counts.append(len(list(machine.states())))
|
|
296
|
+
posterior.log_likelihoods.append(total_loglik)
|
|
297
|
+
|
|
298
|
+
if not posterior.samples:
|
|
299
|
+
raise BayesianInferenceError("no samples retained; increase iterations or lower burn_in/thin")
|
|
300
|
+
return posterior
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _build_moore(
|
|
304
|
+
sweep_states: Sequence[np.ndarray],
|
|
305
|
+
trans: np.ndarray,
|
|
306
|
+
emit: np.ndarray,
|
|
307
|
+
pi0: np.ndarray,
|
|
308
|
+
alphabet: Sequence[Any],
|
|
309
|
+
) -> MooreHMM:
|
|
310
|
+
"""Restrict the sampled parameters to occupied states and build a MooreHMM."""
|
|
311
|
+
occupied = sorted({int(s) for states in sweep_states for s in states})
|
|
312
|
+
index = {state: position for position, state in enumerate(occupied)}
|
|
313
|
+
names: dict[int, Hashable] = {state: f"q{position}" for position, state in enumerate(occupied)}
|
|
314
|
+
|
|
315
|
+
sub_trans = trans[np.ix_(occupied, occupied)]
|
|
316
|
+
row_sums = sub_trans.sum(axis=1, keepdims=True)
|
|
317
|
+
row_sums[row_sums == 0] = 1.0
|
|
318
|
+
sub_trans = sub_trans / row_sums
|
|
319
|
+
|
|
320
|
+
init = pi0[occupied]
|
|
321
|
+
init_total = init.sum()
|
|
322
|
+
init = init / init_total if init_total > 0 else np.full(len(occupied), 1.0 / len(occupied))
|
|
323
|
+
|
|
324
|
+
machine = MooreHMM(
|
|
325
|
+
observation_alphabet=frozenset(alphabet),
|
|
326
|
+
initial_distribution={names[state]: float(init[index[state]]) for state in occupied},
|
|
327
|
+
)
|
|
328
|
+
for state in occupied:
|
|
329
|
+
machine.graph.add_state(names[state])
|
|
330
|
+
for state in occupied:
|
|
331
|
+
machine.set_emission_distribution(
|
|
332
|
+
names[state],
|
|
333
|
+
{symbol: float(emit[state, j]) for j, symbol in enumerate(alphabet)},
|
|
334
|
+
)
|
|
335
|
+
for target in occupied:
|
|
336
|
+
prob = float(sub_trans[index[state], index[target]])
|
|
337
|
+
if prob > 0.0:
|
|
338
|
+
machine.add_transition(names[state], names[target], prob)
|
|
339
|
+
machine.validate()
|
|
340
|
+
return machine
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Conjugate Bayesian inference for finite-order Markov chains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterable, Sequence
|
|
6
|
+
from itertools import product
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy.special import polygamma
|
|
11
|
+
|
|
12
|
+
from sofic.generators.mealy import MealyHMM
|
|
13
|
+
from sofic.graph import ATTR_EMISSION, ATTR_PROB
|
|
14
|
+
from sofic.inference.bayesian.counts import (
|
|
15
|
+
BayesianInferenceError,
|
|
16
|
+
WordCountsMC,
|
|
17
|
+
dirichlet_multinomial_log_evidence,
|
|
18
|
+
pretty_symbol,
|
|
19
|
+
pretty_word,
|
|
20
|
+
split_word,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def words_iter(alphabet: Sequence[Any], length: int) -> Iterable[tuple[Any, ...]]:
|
|
25
|
+
return product(tuple(alphabet), repeat=length)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DirichletPriorMC:
|
|
29
|
+
"""Dirichlet row prior for an order-``k`` Markov chain."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, alphabet: Sequence[Any], order: int, uniform: bool = True):
|
|
32
|
+
self.alphabet = tuple(alphabet)
|
|
33
|
+
self.order = int(order)
|
|
34
|
+
self.uniform = uniform
|
|
35
|
+
self.alphas: dict[tuple[tuple[Any, ...], Any], float] = {}
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
lines = []
|
|
39
|
+
for context in words_iter(self.alphabet, self.order):
|
|
40
|
+
lines.append(f"alpha({pretty_word(context)} -> *) = {self.get_alpha((*context, '*'))}")
|
|
41
|
+
for symbol in self.alphabet:
|
|
42
|
+
lines.append(
|
|
43
|
+
f"alpha({pretty_word(context)} -> {pretty_symbol(symbol)}) = {self.get_alpha((*context, symbol))}"
|
|
44
|
+
)
|
|
45
|
+
return "\n".join(lines) + "\n"
|
|
46
|
+
|
|
47
|
+
def create_random_prior(self, lower: int, upper: int, rng: np.random.Generator | None = None) -> None:
|
|
48
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
49
|
+
self.uniform = False
|
|
50
|
+
for word in words_iter(self.alphabet, self.order + 1):
|
|
51
|
+
self.set_alpha(word, int(generator.integers(lower, upper)))
|
|
52
|
+
|
|
53
|
+
def get_alpha(self, word: Sequence[Any]) -> float | None:
|
|
54
|
+
word = tuple(word)
|
|
55
|
+
if len(word) != self.order + 1:
|
|
56
|
+
return None
|
|
57
|
+
if self.uniform:
|
|
58
|
+
return float(len(self.alphabet) if word[-1] == "*" else 1.0)
|
|
59
|
+
return self.alphas.get(split_word(word))
|
|
60
|
+
|
|
61
|
+
def set_alpha(self, word: Sequence[Any], value: float) -> None:
|
|
62
|
+
self.uniform = False
|
|
63
|
+
context, symbol = split_word(word)
|
|
64
|
+
previous = self.alphas.get((context, symbol), 0.0)
|
|
65
|
+
self.alphas[(context, symbol)] = float(value)
|
|
66
|
+
self.alphas[(context, "*")] = self.alphas.get((context, "*"), 0.0) - previous + float(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MarkovChainPosterior:
|
|
70
|
+
"""Posterior over a finite-order Markov chain's transition rows."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, alphabet: Sequence[Any], data: Sequence[Any], order: int, prior_type: str = "uniform"):
|
|
73
|
+
self.alphabet = tuple(alphabet)
|
|
74
|
+
self.order = int(order)
|
|
75
|
+
self.prior_type = prior_type
|
|
76
|
+
self.counts = WordCountsMC(data, self.order)
|
|
77
|
+
self.prior = DirichletPriorMC(self.alphabet, self.order)
|
|
78
|
+
if prior_type == "random":
|
|
79
|
+
self.prior.create_random_prior(1, 6)
|
|
80
|
+
elif prior_type != "uniform":
|
|
81
|
+
raise BayesianInferenceError("unknown Markov-chain prior type")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def contexts(self) -> tuple[tuple[Any, ...], ...]:
|
|
85
|
+
return tuple(words_iter(self.alphabet, self.order))
|
|
86
|
+
|
|
87
|
+
def add_counts_from(self, data: Sequence[Any]) -> None:
|
|
88
|
+
self.counts.add_counts_from(data)
|
|
89
|
+
|
|
90
|
+
def transition_probability_mle(self, word: Sequence[Any], symbol: Any) -> tuple[float, float]:
|
|
91
|
+
context = tuple(word)
|
|
92
|
+
n = self.counts.get_word_count((*context, symbol))
|
|
93
|
+
N = self.counts.get_word_count((*context, "*"))
|
|
94
|
+
if N > 0:
|
|
95
|
+
prob = n / N
|
|
96
|
+
variance = n * (N - n) / N**3
|
|
97
|
+
else:
|
|
98
|
+
prob = variance = 0.0
|
|
99
|
+
return float(prob), float(variance)
|
|
100
|
+
|
|
101
|
+
def transition_probability_pme(self, word: Sequence[Any], symbol: Any) -> tuple[float, float]:
|
|
102
|
+
context = tuple(word)
|
|
103
|
+
n = self.counts.get_word_count((*context, symbol))
|
|
104
|
+
N = self.counts.get_word_count((*context, "*"))
|
|
105
|
+
a = self.prior.get_alpha((*context, symbol))
|
|
106
|
+
A = self.prior.get_alpha((*context, "*"))
|
|
107
|
+
if a is None or A is None:
|
|
108
|
+
raise BayesianInferenceError("missing prior alpha")
|
|
109
|
+
prob = (n + a) / (N + A)
|
|
110
|
+
variance = ((n + a) * (N + A - n - a)) / ((N + A + 1) * (N + A) ** 2)
|
|
111
|
+
return float(prob), float(variance)
|
|
112
|
+
|
|
113
|
+
def posterior_alpha_matrix(self) -> np.ndarray:
|
|
114
|
+
matrix = np.zeros((len(self.contexts), len(self.alphabet)), dtype=float)
|
|
115
|
+
for i, context in enumerate(self.contexts):
|
|
116
|
+
for j, symbol in enumerate(self.alphabet):
|
|
117
|
+
alpha = self.prior.get_alpha((*context, symbol))
|
|
118
|
+
if alpha is None:
|
|
119
|
+
raise BayesianInferenceError("missing prior alpha")
|
|
120
|
+
matrix[i, j] = alpha + self.counts.get_word_count((*context, symbol))
|
|
121
|
+
return matrix
|
|
122
|
+
|
|
123
|
+
def posterior_mean_matrix(self) -> np.ndarray:
|
|
124
|
+
alpha = self.posterior_alpha_matrix()
|
|
125
|
+
return alpha / alpha.sum(axis=1, keepdims=True)
|
|
126
|
+
|
|
127
|
+
def log_evidence(self) -> float:
|
|
128
|
+
evidence = 0.0
|
|
129
|
+
for context in self.contexts:
|
|
130
|
+
root = (*context, "*")
|
|
131
|
+
alpha_root = self.prior.get_alpha(root)
|
|
132
|
+
if alpha_root is None:
|
|
133
|
+
raise BayesianInferenceError("missing prior alpha")
|
|
134
|
+
cells: list[tuple[float, float]] = []
|
|
135
|
+
for symbol in self.alphabet:
|
|
136
|
+
word = (*context, symbol)
|
|
137
|
+
alpha = self.prior.get_alpha(word)
|
|
138
|
+
if alpha is None:
|
|
139
|
+
raise BayesianInferenceError("missing prior alpha")
|
|
140
|
+
cells.append((alpha, self.counts.get_word_count(word)))
|
|
141
|
+
evidence += dirichlet_multinomial_log_evidence(alpha_root, self.counts.get_word_count(root), cells)
|
|
142
|
+
return float(evidence)
|
|
143
|
+
|
|
144
|
+
def average_relative_entropy_plus_entropy_rate(self) -> float:
|
|
145
|
+
beta = 0.0
|
|
146
|
+
for context in self.contexts:
|
|
147
|
+
for symbol in self.alphabet:
|
|
148
|
+
beta += self.counts.get_word_count((*context, symbol)) + self.prior.get_alpha((*context, symbol))
|
|
149
|
+
|
|
150
|
+
result = 0.0
|
|
151
|
+
invlog2 = 1 / np.log(2)
|
|
152
|
+
for context in self.contexts:
|
|
153
|
+
root = (*context, "*")
|
|
154
|
+
n = self.counts.get_word_count(root)
|
|
155
|
+
alpha = self.prior.get_alpha(root)
|
|
156
|
+
prob = (n + alpha) / beta
|
|
157
|
+
result += invlog2 * prob * polygamma(0, n + alpha)
|
|
158
|
+
for symbol in self.alphabet:
|
|
159
|
+
cond, _variance = self.transition_probability_pme(context, symbol)
|
|
160
|
+
ws_count = self.counts.get_word_count((*context, symbol))
|
|
161
|
+
ws_alpha = self.prior.get_alpha((*context, symbol))
|
|
162
|
+
result -= invlog2 * prob * cond * polygamma(0, ws_count + ws_alpha)
|
|
163
|
+
return float(result)
|
|
164
|
+
|
|
165
|
+
def variance_relative_entropy_plus_entropy_rate(self) -> float:
|
|
166
|
+
beta = 0.0
|
|
167
|
+
for context in self.contexts:
|
|
168
|
+
for symbol in self.alphabet:
|
|
169
|
+
beta += self.counts.get_word_count((*context, symbol)) + self.prior.get_alpha((*context, symbol))
|
|
170
|
+
|
|
171
|
+
result = 0.0
|
|
172
|
+
invlog2 = 1 / np.log(2)
|
|
173
|
+
for context in self.contexts:
|
|
174
|
+
root = (*context, "*")
|
|
175
|
+
n = self.counts.get_word_count(root)
|
|
176
|
+
alpha = self.prior.get_alpha(root)
|
|
177
|
+
prob = (n + alpha) / beta
|
|
178
|
+
result -= invlog2 * prob**2 * polygamma(1, n + alpha)
|
|
179
|
+
for symbol in self.alphabet:
|
|
180
|
+
cond, _variance = self.transition_probability_pme(context, symbol)
|
|
181
|
+
ws_count = self.counts.get_word_count((*context, symbol))
|
|
182
|
+
ws_alpha = self.prior.get_alpha((*context, symbol))
|
|
183
|
+
result += invlog2 * prob**2 * cond**2 * polygamma(1, ws_count + ws_alpha)
|
|
184
|
+
return float(result)
|
|
185
|
+
|
|
186
|
+
def transition_probability_mle_iter(self) -> Iterable[tuple[str, str, float, float]]:
|
|
187
|
+
for context in self.contexts:
|
|
188
|
+
for symbol in self.alphabet:
|
|
189
|
+
prob, var = self.transition_probability_mle(context, symbol)
|
|
190
|
+
yield pretty_word(context), pretty_symbol(symbol), prob, var
|
|
191
|
+
|
|
192
|
+
def transition_probability_pme_iter(self) -> Iterable[tuple[str, str, float, float]]:
|
|
193
|
+
for context in self.contexts:
|
|
194
|
+
for symbol in self.alphabet:
|
|
195
|
+
prob, var = self.transition_probability_pme(context, symbol)
|
|
196
|
+
yield pretty_word(context), pretty_symbol(symbol), prob, var
|
|
197
|
+
|
|
198
|
+
def _state_for_context(self, context: tuple[Any, ...]) -> Hashable:
|
|
199
|
+
return context if self.order else "A"
|
|
200
|
+
|
|
201
|
+
def _target_for(self, context: tuple[Any, ...], symbol: Any) -> Hashable:
|
|
202
|
+
return (*context[1:], symbol) if self.order else "A"
|
|
203
|
+
|
|
204
|
+
def generate_mealy_hmm(self, method: str = "PME", threshold: float = 0.0, reduce: bool = True) -> MealyHMM:
|
|
205
|
+
del reduce
|
|
206
|
+
if not 0 <= threshold <= 1:
|
|
207
|
+
raise BayesianInferenceError("threshold must be between 0 and 1")
|
|
208
|
+
if method == "PME":
|
|
209
|
+
matrix = self.posterior_mean_matrix()
|
|
210
|
+
elif method == "MLE":
|
|
211
|
+
matrix = np.array(
|
|
212
|
+
[
|
|
213
|
+
[self.transition_probability_mle(context, symbol)[0] for symbol in self.alphabet]
|
|
214
|
+
for context in self.contexts
|
|
215
|
+
],
|
|
216
|
+
dtype=float,
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
raise BayesianInferenceError("unknown inference method")
|
|
220
|
+
|
|
221
|
+
hmm = MealyHMM(observation_alphabet=frozenset(self.alphabet))
|
|
222
|
+
hmm.name = f"Inferred order-{self.order} Markov chain, {method}"
|
|
223
|
+
states = [self._state_for_context(context) for context in self.contexts]
|
|
224
|
+
initial = {state: 1.0 / len(states) for state in states} if states else {}
|
|
225
|
+
hmm.initial_distribution = initial
|
|
226
|
+
for state in states:
|
|
227
|
+
hmm.graph.add_state(state)
|
|
228
|
+
for i, context in enumerate(self.contexts):
|
|
229
|
+
source = self._state_for_context(context)
|
|
230
|
+
for j, symbol in enumerate(self.alphabet):
|
|
231
|
+
prob = float(matrix[i, j])
|
|
232
|
+
if prob > threshold:
|
|
233
|
+
hmm.graph.add_transition(
|
|
234
|
+
source, self._target_for(context, symbol), **{ATTR_EMISSION: symbol, ATTR_PROB: prob}
|
|
235
|
+
)
|
|
236
|
+
hmm.validate()
|
|
237
|
+
return hmm
|
|
238
|
+
|
|
239
|
+
def sample_mealy_hmms(
|
|
240
|
+
self,
|
|
241
|
+
n: int = 1,
|
|
242
|
+
threshold: float = 0.0,
|
|
243
|
+
reduce: bool = True,
|
|
244
|
+
rng: np.random.Generator | None = None,
|
|
245
|
+
) -> Iterable[MealyHMM]:
|
|
246
|
+
del reduce
|
|
247
|
+
if not 0 <= threshold <= 1:
|
|
248
|
+
raise BayesianInferenceError("threshold must be between 0 and 1")
|
|
249
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
250
|
+
alpha = self.posterior_alpha_matrix()
|
|
251
|
+
samples = np.stack([generator.dirichlet(row, size=n) for row in alpha], axis=1)
|
|
252
|
+
for sample in samples:
|
|
253
|
+
hmm = MealyHMM(observation_alphabet=frozenset(self.alphabet))
|
|
254
|
+
hmm.name = f"Sampled order-{self.order} Markov chain"
|
|
255
|
+
states = [self._state_for_context(context) for context in self.contexts]
|
|
256
|
+
hmm.initial_distribution = {state: 1.0 / len(states) for state in states}
|
|
257
|
+
for state in states:
|
|
258
|
+
hmm.graph.add_state(state)
|
|
259
|
+
for i, context in enumerate(self.contexts):
|
|
260
|
+
source = self._state_for_context(context)
|
|
261
|
+
for j, symbol in enumerate(self.alphabet):
|
|
262
|
+
prob = float(sample[i, j])
|
|
263
|
+
if prob > threshold:
|
|
264
|
+
hmm.graph.add_transition(
|
|
265
|
+
source, self._target_for(context, symbol), **{ATTR_EMISSION: symbol, ATTR_PROB: prob}
|
|
266
|
+
)
|
|
267
|
+
hmm.validate()
|
|
268
|
+
yield hmm
|
|
269
|
+
|
|
270
|
+
def as_pymc_model(self, *, observed_as_counts: bool = False) -> Any:
|
|
271
|
+
from sofic.inference.bayesian.pymc_backend import markov_chain_model
|
|
272
|
+
|
|
273
|
+
return markov_chain_model(self, observed_as_counts=observed_as_counts)
|
|
274
|
+
|
|
275
|
+
def counts_string(self) -> str:
|
|
276
|
+
return str(self.counts)
|
|
277
|
+
|
|
278
|
+
def prior_string(self) -> str:
|
|
279
|
+
return str(self.prior)
|
|
280
|
+
|
|
281
|
+
def transition_probability_mle_string(self) -> str:
|
|
282
|
+
return "".join(
|
|
283
|
+
f"Pr( {symbol} | {context} ) = {prob:.8f} , StdDev = {np.sqrt(var):.8f}\n"
|
|
284
|
+
for context, symbol, prob, var in self.transition_probability_mle_iter()
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def transition_probability_pme_string(self) -> str:
|
|
288
|
+
return "".join(
|
|
289
|
+
f"Pr( {symbol} | {context} ) = {prob:.8f} , StdDev = {np.sqrt(var):.8f}\n"
|
|
290
|
+
for context, symbol, prob, var in self.transition_probability_pme_iter()
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
InferMC = MarkovChainPosterior
|