ergodicDocs
Products: switch from ergodic to another product
Get started

ReferenceHidden regimes

Hidden regimes

Hidden-regime inference: recovering a switch nobody logged. Import from ergodic.latent.

One observed series; a hidden two-state process (call it the switch); a mechanism that changes how the series behaves while the switch is on. This pillar asks three questions and keeps them apart on purpose. recover() answers WHEN the switch was on and WHICH named mechanism block changed: it returns the posterior state path and a delta interval per block (level, spread, rhythm, amplitude, in the built-in vocabularies). certify() answers WHETHER a regime exists at all, vocabulary-free: it fits the likelihood's own one-state null, simulates control worlds from it, and places the observed two-state improvement in that measured null distribution. calibrate() measures a configured detector's false-fire rate on the same kind of control worlds, with a Wilson interval on the rate. Conflating the three is the standard failure of switching models: a fitted path always explains the data it was fitted to, so "which block moved" must never double as "a regime exists". consensus() lays several likelihoods' verdicts on one series side by side, absences (a vocabulary that cannot express a block) kept distinct from non-fires.

The model underneath is one hidden Markov chain whose path is summed out exactly by the forward algorithm, so NUTS samples only continuous parameters. What varies is the emission: a RegimeLikelihood says how the regime shows up in the data, declares its delta blocks with shrinkage priors, and everything else (chain priors, marginalization, sampling, decoding, label alignment, verdicts) is shared engine. Two likelihoods ship: spectral_whittle(), the fast flagship that reads level, spread, rhythm, and amplitude off framed local spectra, and switching_ar(), the autoregressive second implementation that proves the contract generalizes and is documented blind to amplitude (a sinusoid's AR coefficients do not depend on its amplitude).

Label switching is handled by alignment, never by ordering constraints: decoded state trajectories are correlated against an iterated reference and flipped draws have their deltas sign-corrected, so the posterior stays untruncated and a delta near zero is not pushed off it. Verdicts are equal-tailed credible intervals at a familywise-corrected level, gated by the likelihood's own sanity checks (the spectral wave gate). The measured sentinels behind those choices ride in the module docstrings: the spectral model fires on zero of twenty control worlds at the 99% familywise level and recovers a planted noise tripling as a log-spread delta near 1.1, and the switching AR fires six of six on level, spread, and rhythm arms while its amplitude blindness stands.

pymc is imported inside the functions that sample, so the bare install never loads it; pip install 'ergodic[latent]' adds it, and jax plus numpyro get the fast sampler path.

Also exported from ergodic: CalibrationReport, CertifyResult, Consensus, LatentResult, MarkovChain, RegimeLikelihood, calibrate, certify, consensus, latent_model, recover.

CalibrationReport#

python
class CalibrationReport(
    n: int,
    fires: int,
    rate: float,
    ci_lo: float,
    ci_hi: float,
    worlds: tuple[CalibrationWorld, ...],
) -> None

The measured false-fire rate of one configured detector on control worlds.

n control worlds were simulated from the likelihood's fitted one-state null on the observed series and each ran through the full recover pipeline; a world counts as fired when any block's verdict came back true. rate is fires / n and the interval is Wilson's, which stays honest at 0 of n and n of n where the textbook interval collapses to a point.

A detector that fires on control worlds is not slightly wrong, it is uninformative: every fire on real data then carries the same doubt. The spectral likelihood's own history says why this report exists: an intermediate version fired on 45% of control worlds and looked excellent on effect worlds while doing it.

Field Type Default
n int required
fires int required
rate float required
ci_lo float required
ci_hi float required
worlds tuple[CalibrationWorld, ...] required

CalibrationReport.summary#

python
def CalibrationReport.summary() -> str

One sentence, the number a decision reads.

CalibrationReport.to_frame#

python
def CalibrationReport.to_frame() -> pd.DataFrame

Per-world rows: world, seed, fired, fired_blocks.

CertifyResult#

python
class CertifyResult(observed: float, null: Array, p_value: float) -> None

Whether a regime exists at all: the observed improvement in its own null.

observed is twice the log-likelihood gain of the two-state model over the likelihood's fitted one-state null on the real series; null holds the same statistic recomputed on control worlds simulated from that null, one entry per world. p_value is the standard simulation p, (1 + #{null >= observed}) / (1 + B): with B control worlds the finest attainable evidence is 1 / (1 + B), and reporting zero would claim more resolution than the simulation bought.

This is deliberately a different object from verdicts(). A delta interval says which block moved given a regime; this record says whether the regime story beats a world that never had one, with the null distribution measured rather than assumed (the BIC alternative prices an extra state at a fixed ln n per parameter, and the reference lab measured that price wrong in both directions on these worlds).

Field Type Default
observed float required
null Array required
p_value float required

CertifyResult.n_null#

python
property CertifyResult.n_null: int

How many control worlds the null distribution holds.

CertifyResult.to_frame#

python
def CertifyResult.to_frame() -> pd.DataFrame

The whole test as a frame: one row per statistic, observed first.

Columns statistic and source ("observed" or "null"), so the null histogram and the observed line plot straight off it.

Consensus#

python
class Consensus(
    target: str,
    models: tuple[str, ...],
    verdicts: Mapping[str, tuple[bool | None, ...]],
    level: float,
) -> None

Several likelihoods' verdicts on one series, laid side by side.

Each model speaks its own vocabulary, so a block can be missing from a model rather than quiet in it: verdicts maps each block to one entry per model, True/False where the model declares the block and None where it does not (the switching AR has no amplitude block, and that absence is structural, not a non-fire). agreed collects the blocks every declaring model settles the same way; contested the blocks where declaring models disagree, which is where a cross-model rule would adjudicate.

One rule is not available: the amp/spread rule (a spectral amplitude fire against a volatility model's spread silence separates a louder wave from a noisier floor) needs a vol_garch likelihood that ergodic does not have, so amp_vs_spread() raises by name.

Field Type Default
target str required
models tuple[str, ...] required
verdicts Mapping[str, tuple[bool | None, ...]] required
level float required

Consensus.blocks#

python
property Consensus.blocks: tuple[str, ...]

Every block any model declares, in first-seen order.

Consensus.agreed#

python
property Consensus.agreed: dict[str, bool]

Blocks where every model that declares them says the same thing.

Consensus.contested#

python
property Consensus.contested: tuple[str, ...]

Blocks where declaring models disagree; a cross-model rule's domain.

Consensus.amp_vs_spread#

python
def Consensus.amp_vs_spread() -> Any

Reserved: the amplitude-versus-spread cross-model rule.

A spectral amplitude fire and a volatility model's spread silence together separate "the wave got louder" from "the noise floor rose", which no single built-in likelihood can do alone. The rule needs a vol_garch likelihood, which is not available, so this raises.

Consensus.to_frame#

python
def Consensus.to_frame() -> pd.DataFrame

One row per block: a verdict column per model, then the consensus.

Model verdicts are True/False/None (None: not in that model's vocabulary); consensus is "fires"/"quiet" when the declaring models agree and "contested" when they do not.

DeltaBlock#

python
class DeltaBlock(
    name: str,
    site: str,
    transform: Callable[[Array], Array] | None = None,
) -> None

One named mechanism block: a verdict-bearing site of the emission model.

name is the vocabulary word ("level", "spread"), site the full site name whose posterior draws measure the state-1-minus-state-0 difference. transform reduces a vector-valued site to the scalar the verdict reads; it receives the sign-aligned draws of the whole decode subsample at once (shape (S, ...)), because a sensible reduction (the dominant component of a lag-coefficient delta, say) is a property of the posterior, and returns shape (S,).

Field Type Default
name str required
site str required
transform Callable[[Array], Array] | None None

EmissionSpec#

python
class EmissionSpec(
    loglik: Any,
    deltas: tuple[DeltaBlock, ...],
    gates: tuple[Gate, ...] = (),
) -> None

What emissions() returns to the engine.

loglik is the emission log-density tensor: (T, K), or (T, K, K) for an emission that conditions on the previous state as well. deltas are the declared mechanism blocks, gates the vetoes.

Field Type Default
loglik Any required
deltas tuple[DeltaBlock, ...] required
gates tuple[Gate, ...] ()

Gate#

python
class Gate(
    name: str,
    blocks: tuple[str, ...],
    check: Callable[[Mapping[str, Array]], tuple[float, bool]],
) -> None

A likelihood-declared sanity condition that can veto named verdicts.

A delta interval can exclude zero for a parameter the data never pinned down, so a likelihood may require its own evidence before a verdict stands: the spectral model's wave gate demands that the fitted line actually clears the noise continuum before a frequency or amplitude verdict is allowed to fire. check receives the decode subsample's posterior draws under bare parameter names and returns the gate statistic and whether it passed. A gate should be label-invariant (built from max or symmetric functions over the two states), since it runs on raw, unaligned draws.

Field Type Default
name str required
blocks tuple[str, ...] required
check Callable[[Mapping[str, Array]], tuple[float, bool]] required

GateOutcome#

python
class GateOutcome(
    name: str,
    blocks: tuple[str, ...],
    value: float,
    passed: bool,
) -> None

One gate's measured statistic and whether it passed.

blocks names the delta blocks this gate vetoes when it fails; the statistic and its meaning are the declaring likelihood's own (the spectral wave gate's is the 3rd percentile of the line's log-power excess over the continuum under its peak).

Field Type Default
name str required
blocks tuple[str, ...] required
value float required
passed bool required

LatentModel#

python
class LatentModel(
    model: RegimeLikelihood,
    options: Mapping[str, Any] = <factory>,
) -> None

A validated recover() configuration, held as a value.

Built by latent_model(); everything recover() accepts except the data and the target lives in options. run applies it. The calibration harness deep-copies one of these per control world, which is why the configuration is a frozen value rather than a closure.

Field Type Default
model RegimeLikelihood required
options Mapping[str, Any] factory

LatentModel.run#

python
def LatentModel.run(data: TimeSeriesData, target: str) -> LatentResult

Recover with this configuration.

LatentResult#

python
class LatentResult(
    target: str,
    gammas: Array,
    flips: BoolArray,
    delta_draws: Mapping[str, Array],
    gates: tuple[GateOutcome, ...],
    tick_multiplier: int,
    offset: int,
    chain: MarkovChain,
    likelihood: Any,
    n_draws: int,
    divergences: int | None = None,
    rhat_max: float | None = None,
    rhat_max_unaligned: float | None = None,
) -> None

Posterior state path and mechanism deltas for one recovered switch.

gammas is (S, T_eff): the decode subsample's aligned posterior probability of state 1 per emission step. delta_draws maps each declared block to its (S,) sign-aligned draws. tick_multiplier and offset place the emission steps back on the original series' clock: step t covers ticks offset + t * tick_multiplier onward.

Arrays are copied in and frozen, so the result owns its draws.

Field Type Default
target str required
gammas Array required
flips BoolArray required
delta_draws Mapping[str, Array] required
gates tuple[GateOutcome, ...] required
tick_multiplier int required
offset int required
chain MarkovChain required
likelihood Any required
n_draws int required
divergences int | None None
rhat_max float | None None
rhat_max_unaligned float | None None

LatentResult.n_decoded#

python
property LatentResult.n_decoded: int

How many posterior draws were decoded and aligned.

LatentResult.n_steps#

python
property LatentResult.n_steps: int

Emission steps in the decoded path.

LatentResult.blocks#

python
property LatentResult.blocks: tuple[str, ...]

The declared delta blocks, in declaration order.

LatentResult.gamma#

python
def LatentResult.gamma() -> Array

Posterior state marginals, (T_eff, K): the mean over decoded draws.

LatentResult.path#

python
def LatentResult.path() -> IntArray

The hard path at emission resolution: state 1 where its marginal clears one half.

LatentResult.tick_path#

python
def LatentResult.tick_path() -> IntArray

The hard path on the original clock, one entry per tick.

Each emission step's state is repeated over the ticks it covers. The array starts at tick offset of the original series and ends at offset + n_steps * tick_multiplier; ticks the preparation consumed or truncated carry no state and are simply absent.

LatentResult.deltas#

python
def LatentResult.deltas(
    level: float = 0.94,
) -> dict[str, tuple[float, float, float]]

Equal-tailed (lo, mean, hi) per block at level, no correction.

This is the descriptive read of the posterior; verdicts() is the corrected, gated decision.

LatentResult.verdicts#

python
def LatentResult.verdicts(level: float = 0.99) -> dict[str, bool]

Which blocks moved: zero-exclusion at familywise level, gates applied.

The per-block interval runs at the Bonferroni share 1 - (1 - level) / n_blocks, so with the default level and four blocks each interval is equal-tailed at 99.75%, and the chance any inert block fires stays at or under one percent. A block a failed gate names is vetoed whatever its interval says.

LatentResult.to_frame#

python
def LatentResult.to_frame() -> pd.DataFrame

The decoded path as a frame: one row per emission step.

Columns: step, tick (the step's first tick on the original clock), gamma (posterior probability of state 1), state (the hard path).

MarkovChain#

python
class MarkovChain(stay_alpha: float = 60.0, stay_beta: float = 1.0) -> None

The prior on the hidden chain: stay ~ Beta(stay_alpha, stay_beta) per state.

The stay probability is the whole personality of a two-state chain, and the prior on it is where dwell knowledge enters. The default, Beta(60, 1), concentrates near one: a regime with a mean dwell around 240 ticks stays put with probability about 0.996 per tick, and a prior that allows rapid flip-flopping invites the sampler to explain noise with a busy path. A likelihood whose emission step spans many ticks supplies its own default at its own resolution through default_chain().

The initial distribution is always Dirichlet(1, 1); there is nothing worth configuring in where a long chain happens to start.

Field Type Default
stay_alpha float 60.0
stay_beta float 1.0

PreparedData#

python
class PreparedData(
    obs: Mapping[str, Array],
    T_eff: int,
    tick_multiplier: int = 1,
    offset: int = 0,
) -> None

What prepare() hands the engine: numpy only.

obs holds the named arrays the emission reads (frame means and periodograms, or a lag matrix), already standardized. T_eff is the number of emission steps, which is also the length of the decoded path. tick_multiplier says how many ticks of the original series one emission step covers (96 for a spectral frame, 1 for an AR step), and offset how many ticks the preparation consumed at the start (the lag order for an autoregression). Together they place the decoded path back on the original clock.

Field Type Default
obs Mapping[str, Array] required
T_eff int required
tick_multiplier int 1
offset int 0

RegimeLikelihood#

python
class RegimeLikelihood(k: int = 2) -> None

The unit of extension: how a hidden regime shows up in one series.

Subclass as a frozen dataclass and implement four methods. prepare turns the raw series into the numpy tensors the emission reads. emissions runs inside the active pm.Model(), registers every prior site as f"{name}_{param}", and returns the emission log-density tensor with the declared delta blocks and gates. emissions_np is the numpy mirror of the same density for one posterior draw, which is what the decode's forward-backward runs on; it has to be the same math, because a decode under a different density than the fit answers a different question. default_chain states the transition prior at this likelihood's own step resolution.

null_loglik and simulate_null are the one-state null model that certify() and calibrate() read: the log-likelihood of the best regime-free fit on the prepared series, and a fresh control world drawn from that same fit. The two must describe the same fitted null, or the p-values built on them answer a question nobody asked.

k is fixed at two states: one switch, on or off. More states change the delta vocabulary (which pair differs?) and are a design of their own, so anything else is refused here rather than half-supported.

Field Type Default
k int 2

RegimeLikelihood.default_chain#

python
def RegimeLikelihood.default_chain() -> MarkovChain

The transition prior at this likelihood's step resolution.

RegimeLikelihood.prepare#

python
def RegimeLikelihood.prepare(y: Array) -> PreparedData

Numpy-only: standardize, frame, or lag-slice y into emission inputs.

RegimeLikelihood.emissions#

python
def RegimeLikelihood.emissions(
    name: str,
    data: PreparedData,
) -> EmissionSpec

Register priors inside the active pm.Model() and return the emission spec.

RegimeLikelihood.emissions_np#

python
def RegimeLikelihood.emissions_np(
    data: PreparedData,
    draw: Mapping[str, Array],
) -> Array

The numpy mirror of emissions() for one draw: (T, K) log-densities.

RegimeLikelihood.null_loglik#

python
def RegimeLikelihood.null_loglik(data: PreparedData) -> float

The fitted one-state null's log-likelihood on the prepared series.

This is the regime-free half of certify()'s improvement statistic, so it must be conditioned exactly as the two-state marginal log-likelihood is (the autoregression conditions both on the first p ticks), or the two are not comparable.

RegimeLikelihood.simulate_null#

python
def RegimeLikelihood.simulate_null(
    data: PreparedData,
    rng: np.random.Generator,
) -> Array

A fresh control world drawn from the same fitted one-state null.

Returned in the prepared series' own (standardized) space, at the original series length, so it can go straight back through prepare(). certify() builds its null distribution from these worlds and calibrate() measures the detector's fire rate on them.

calibrate#

python
def calibrate(
    spec: LatentModel,
    data: TimeSeriesData,
    target: str,
    *,
    n_controls: int = 20,
    seed: int = 0,
) -> CalibrationReport

Is the configured detector honest: measured fire-rate on control worlds.

Fits the likelihood's one-state null on the observed series, simulates n_controls regime-free worlds from it, runs the full recover() pipeline (the spec's own budgets and options) on each, and reports how often any verdict fired, with a Wilson interval on the rate. World i runs on a deep copy of the spec with sampler seed seed + i, so the worlds are independent and the whole report reproduces from its seed.

This is the harness that turned the spectral likelihood from a detector that fired on 45% of control worlds into one that fired on 0 of 20 (the ENBW deflation and the per-frame detrend were the fixes, and this measurement is how they were found). Run it once per configuration you intend to trust, on a series from the domain you intend to trust it on: the control worlds inherit the observed series' own memory and scale, so the rate is about your detector on your data, not a generic promise.

certify#

python
def certify(
    data: TimeSeriesData,
    target: str,
    *,
    model: RegimeLikelihood,
    chain: MarkovChain | None = None,
    n_null: int = 99,
    seed: int = 0,
    refit_draws: int = 200,
    refit_warmup: int = 200,
) -> CertifyResult

WHETHER a regime exists at all: a simulated-null likelihood-ratio test.

The statistic is twice the log-likelihood gain of the two-state model over the likelihood's own fitted one-state null: the two-state marginal log-likelihood (hidden path summed out by the forward algorithm) at a cheap point estimate of the parameters, minus model.null_loglik. The null distribution is measured, not assumed: n_null control worlds are drawn from the fitted one-state model (model.simulate_null) and each is pushed through the identical computation. The p-value is (1 + #{null >= observed}) / (1 + n_null), so with the default 99 worlds the finest attainable evidence is 0.01, and reporting less would claim resolution the simulation did not buy.

The cheap refit is a short single-chain posterior sample (refit_draws after refit_warmup, seed fixed across worlds), with the statistic evaluated at the posterior-mean parameters. The alternative, a pm.find_MAP point, was measured on one world of each likelihood and rejected: on the spectral model the Laplace shrinkage pulls the MAP's deltas to zero and the "improvement" came out negative on a genuine level world (-7.3 against the short sample's +25.8) while also running about seven times slower (30 s against 4 s per world, the optimizer recompiling per world where the short sample amortizes); on the AR model both were adequate (91.2 against 90.2 on the same world) and only there was MAP faster. The calibration argument for the choice is that the observed series and every null world go through exactly the same procedure, budget and seed included, so under the null the observed statistic is exchangeable with the simulated ones and the p-value is honest by construction; a fancier fit for the observed side alone would inflate the observed statistic relative to its null and manufacture significance.

Budget honestly stated: one short fit per world, so the default n_null=99 costs about 100 short fits (minutes on the spectral flagship, longer on the tick-resolution AR). Trim n_null for a coarser but faster answer; the p-value's floor rises with it.

Returns a CertifyResult with the observed statistic, the null array, and the p-value.

consensus#

python
def consensus(
    results: Sequence[LatentResult],
    *,
    level: float = 0.99,
) -> Consensus

Several likelihoods' verdicts on one series, block by block.

Each model speaks its own vocabulary, so the table distinguishes three things a flat comparison would blur: a block both models fired, a block they disagree on, and a block one model cannot express at all (the switching AR's structural amplitude blindness is an absence, not a non-fire, and is shown as None). Verdicts are recomputed from each result's delta draws at the shared level, familywise-corrected and gated exactly as verdicts() reports them.

One cross-model rule is not available: adjudicating a spectral amplitude fire against a volatility model's spread verdict (a louder wave against a noisier floor) needs a vol_garch likelihood that ergodic does not have, so amp_vs_spread() raises by name.

latent_model#

python
def latent_model(*, model: RegimeLikelihood, **options: Any) -> LatentModel

Configuration now, data later: recover()'s keywords as a frozen spec.

Validated eagerly. A keyword recover() does not take is refused here, and so is a chain of the wrong type, because the consumer of this spec is calibrate(), where the first use of a bad option would otherwise be twenty NUTS runs in.

recover#

python
def recover(
    data: TimeSeriesData,
    target: str,
    *,
    model: RegimeLikelihood,
    chain: MarkovChain | None = None,
    graph: Any | None = None,
    draws: int = 1000,
    warmup: int = 1000,
    chains: int = 4,
    seed: int = 0,
    decode_draws: int = 150,
) -> LatentResult

Recover a hidden two-state regime from one observed series.

Fits the switching model model describes (its emission, its declared mechanism deltas, its gates) with the hidden path marginalized out, then decodes the path per posterior draw and aligns the labels. The result carries the posterior state marginals, the hard path, the sign-aligned delta draws, and the familywise-corrected verdicts.

Parameters data: A TimeSeriesData; rows are already in time order. Panel and hierarchical inputs are refused by name. target: The continuous column to read the regime from. model: A RegimeLikelihood: spectral_whittle() or switching_ar(), or your own subclass. chain: The transition prior as a MarkovChain. None means the likelihood's own default at its emission resolution, which is almost always what you want: dwell knowledge stated per tick is wrong by a factor of the frame length for a framed likelihood. graph: Not supported: one regime drives one series; pass nothing. draws, warmup, chains, seed: The sampler budget and seed. The defaults are the measured reference configuration (4 chains of 1000 after 1000 warmup). decode_draws: How many posterior draws to decode and align (evenly spaced over the posterior). The path marginals stabilize well before the full posterior is spent, and each decoded draw costs a forward-backward pass.

Returns LatentResult Frozen: state marginals, hard path, delta draws, gates, verdicts, diagnostics.

semi_markov#

python
def semi_markov(*, min_dwell: int) -> MarkovChain

Reserved: a chain with an explicit dwell-time distribution.

A geometric dwell is what a Markov chain can express, and the worlds this pillar was measured on have negative-binomial dwells, which the Beta stay prior only approximates. An explicit semi-Markov chain (dwell floor, or a full dwell distribution) needs its own forward pass and is not available, so this raises.

Suggest an edit
Documentation

Search ergodic 0.2.0

Search ergodic by task, function or error.