Skip to content

Add posterior function to estimate MLE/MAP elbo, responsibility, and smoothed states - #161

Open
harrisonritz wants to merge 15 commits into
devfrom
CCN2026
Open

Add posterior function to estimate MLE/MAP elbo, responsibility, and smoothed states#161
harrisonritz wants to merge 15 commits into
devfrom
CCN2026

Conversation

@harrisonritz

Copy link
Copy Markdown
Collaborator

Iterates discrete/continuous smoothing until convergence, then exports elbo, responsibilities, and states. Takes a while to run to completion, so ideally for post-fit eval.

  • want a better interface to choose what is exported -- maybe an export dict?

  • export smoothed cov, but make it optional (big variable)

  • remove kluge regularization of discrete parameters

  • maybe we create a function to share with the estep? Eg could have option to run multiple smoothing iterations during estep (iirc you said 2-3 iter can give much (?) better updates). Less surface for estep revisions

harrisonritz and others added 12 commits July 20, 2026 16:18
Add a public `infer_γ` (exported) in `fit_SLDS.jl` that infers the discrete-state
responsibilities γₜ(k) = q(zₜ = k) of a fitted SLDS with parameters held fixed
(no M-step). It runs the structured-variational E-step, alternating the
forward-backward pass over the switching chain q(z) with the Laplace/Kalman
smoother over the continuous states q(x) — the classic coordinate-ascent E-step
of Ghahramani & Hinton — either until the responsibilities converge (max|Δγ| <
tol, capped by max_iter) or for a fixed number of iterations (check_convergence
flag).

The two smoothers are coupled deterministically: the discrete-layer
log-likelihoods are plugged in at the smoothed posterior mean rather than a
Monte-Carlo draw, so unlike fit!'s learning-time E-step the iteration is
reproducible and γ settles to a fixed point, making the convergence check well
behaved.

Returns γ matching the input shape family: a K×T matrix for single-trial matrix
input, otherwise a Vector of per-trial K×T_i matrices (ragged lengths allowed).

Includes tests (shapes, determinism, both stopping modes, K=1 degenerate case,
and discrete-state recovery on well-separated regimes) and a docs section.
Co-authored-by: Ryan Senne <50930199+rsenne@users.noreply.github.com>
@rsenne

rsenne commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

I'll do my best to review this today, thought it's a big diff so may take me a day or two :) thanks!

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia v1)

Time benchmarks
main 73bd17d... main / 73bd17d...
GaussianLDS/("smooth", "latent=2", "obs=10", "T=100") 0.21 ± 0.012 ms 0.207 ± 0.013 ms 1.02 ± 0.086
GaussianLDS/("smooth", "latent=2", "obs=10", "T=500") 1.01 ± 0.07 ms 0.97 ± 0.013 ms 1.04 ± 0.074
GaussianLDS/("smooth", "latent=2", "obs=20", "T=100") 0.221 ± 0.022 ms 0.212 ± 0.0094 ms 1.04 ± 0.11
GaussianLDS/("smooth", "latent=2", "obs=20", "T=500") 0.998 ± 0.066 ms 0.973 ± 0.055 ms 1.03 ± 0.089
GaussianLDS/("smooth", "latent=2", "obs=5", "T=100") 0.204 ± 0.0096 ms 0.201 ± 0.0049 ms 1.02 ± 0.054
GaussianLDS/("smooth", "latent=2", "obs=5", "T=500") 0.993 ± 0.065 ms 0.963 ± 0.0088 ms 1.03 ± 0.068
GaussianLDS/("smooth", "latent=4", "obs=10", "T=100") 0.395 ± 0.009 ms 0.387 ± 0.014 ms 1.02 ± 0.043
GaussianLDS/("smooth", "latent=4", "obs=10", "T=500") 1.84 ± 0.019 ms 1.82 ± 0.023 ms 1.02 ± 0.016
GaussianLDS/("smooth", "latent=4", "obs=20", "T=100") 0.406 ± 0.012 ms 0.391 ± 0.011 ms 1.04 ± 0.042
GaussianLDS/("smooth", "latent=4", "obs=20", "T=500") 1.89 ± 0.59 ms 1.82 ± 0.027 ms 1.04 ± 0.32
GaussianLDS/("smooth", "latent=4", "obs=5", "T=100") 0.401 ± 0.025 ms 0.385 ± 0.0098 ms 1.04 ± 0.069
GaussianLDS/("smooth", "latent=4", "obs=5", "T=500") 1.84 ± 0.019 ms 1.82 ± 0.049 ms 1.01 ± 0.029
GaussianLDS/("smooth", "latent=8", "obs=10", "T=100") 0.942 ± 0.028 ms 0.918 ± 0.034 ms 1.03 ± 0.049
GaussianLDS/("smooth", "latent=8", "obs=10", "T=500") 6.81 ± 3.7 ms 6.64 ± 1.7 ms 1.03 ± 0.62
GaussianLDS/("smooth", "latent=8", "obs=20", "T=100") 0.956 ± 0.031 ms 0.918 ± 0.019 ms 1.04 ± 0.04
GaussianLDS/("smooth", "latent=8", "obs=20", "T=500") 5.62 ± 3.6 ms 6.52 ± 2.2 ms 0.861 ± 0.63
PoissonLDS/("smooth", "latent=2", "obs=10", "T=100") 1.11 ± 0.022 ms 0.967 ± 0.011 ms 1.15 ± 0.026
PoissonLDS/("smooth", "latent=2", "obs=10", "T=500") 5.57 ± 0.11 ms 4.87 ± 0.044 ms 1.14 ± 0.024
PoissonLDS/("smooth", "latent=2", "obs=5", "T=100") 1.17 ± 0.0095 ms 1 ± 0.011 ms 1.16 ± 0.016
PoissonLDS/("smooth", "latent=2", "obs=5", "T=500") 6.34 ± 0.084 ms 5.44 ± 0.028 ms 1.16 ± 0.017
PoissonLDS/("smooth", "latent=4", "obs=10", "T=100") 1.98 ± 0.064 ms 1.71 ± 0.0082 ms 1.16 ± 0.038
PoissonLDS/("smooth", "latent=4", "obs=10", "T=500") 10.7 ± 0.069 ms 9.27 ± 0.045 ms 1.15 ± 0.0093
PoissonLDS/("smooth", "latent=4", "obs=5", "T=100") 1.58 ± 0.012 ms 1.34 ± 0.0095 ms 1.18 ± 0.012
PoissonLDS/("smooth", "latent=4", "obs=5", "T=500") 9.96 ± 0.052 ms 8.51 ± 0.081 ms 1.17 ± 0.013
time_to_load 0.792 ± 0.0027 s 0.822 ± 0.0052 s 0.964 ± 0.007
Memory benchmarks
main 73bd17d... main / 73bd17d...
GaussianLDS/("smooth", "latent=2", "obs=10", "T=100") 2.08 k allocs: 0.265 MB 2.05 k allocs: 0.235 MB 1.13
GaussianLDS/("smooth", "latent=2", "obs=10", "T=500") 9.3 k allocs: 1.24 MB 9.27 k allocs: 1.09 MB 1.14
GaussianLDS/("smooth", "latent=2", "obs=20", "T=100") 2.09 k allocs: 0.298 MB 2.06 k allocs: 0.259 MB 1.15
GaussianLDS/("smooth", "latent=2", "obs=20", "T=500") 9.31 k allocs: 1.31 MB 9.28 k allocs: 1.11 MB 1.17
GaussianLDS/("smooth", "latent=2", "obs=5", "T=100") 2.08 k allocs: 0.254 MB 2.05 k allocs: 0.227 MB 1.12
GaussianLDS/("smooth", "latent=2", "obs=5", "T=500") 9.3 k allocs: 1.22 MB 9.27 k allocs: 1.08 MB 1.12
GaussianLDS/("smooth", "latent=4", "obs=10", "T=100") 2.09 k allocs: 0.716 MB 2.06 k allocs: 0.626 MB 1.14
GaussianLDS/("smooth", "latent=4", "obs=10", "T=500") 9.3 k allocs: 3.48 MB 9.27 k allocs: 3.03 MB 1.15
GaussianLDS/("smooth", "latent=4", "obs=20", "T=100") 2.1 k allocs: 0.75 MB 2.07 k allocs: 0.652 MB 1.15
GaussianLDS/("smooth", "latent=4", "obs=20", "T=500") 9.31 k allocs: 3.55 MB 9.28 k allocs: 3.06 MB 1.16
GaussianLDS/("smooth", "latent=4", "obs=5", "T=100") 2.09 k allocs: 0.704 MB 2.06 k allocs: 0.618 MB 1.14
GaussianLDS/("smooth", "latent=4", "obs=5", "T=500") 9.3 k allocs: 3.45 MB 9.27 k allocs: 3.02 MB 1.14
GaussianLDS/("smooth", "latent=8", "obs=10", "T=100") 2.09 k allocs: 2.48 MB 2.06 k allocs: 2.16 MB 1.15
GaussianLDS/("smooth", "latent=8", "obs=10", "T=500") 9.3 k allocs: 12.3 MB 9.27 k allocs: 10.7 MB 1.15
GaussianLDS/("smooth", "latent=8", "obs=20", "T=100") 2.1 k allocs: 2.52 MB 2.07 k allocs: 2.19 MB 1.15
GaussianLDS/("smooth", "latent=8", "obs=20", "T=500") 9.31 k allocs: 12.3 MB 9.28 k allocs: 10.7 MB 1.15
PoissonLDS/("smooth", "latent=2", "obs=10", "T=100") 3.32 k allocs: 0.283 MB 2.05 k allocs: 0.234 MB 1.21
PoissonLDS/("smooth", "latent=2", "obs=10", "T=500") 15.3 k allocs: 1.33 MB 9.27 k allocs: 1.09 MB 1.22
PoissonLDS/("smooth", "latent=2", "obs=5", "T=100") 3.53 k allocs: 0.276 MB 2.05 k allocs: 0.228 MB 1.21
PoissonLDS/("smooth", "latent=2", "obs=5", "T=500") 17.4 k allocs: 1.34 MB 9.27 k allocs: 1.09 MB 1.23
PoissonLDS/("smooth", "latent=4", "obs=10", "T=100") 3.54 k allocs: 0.738 MB 2.06 k allocs: 0.626 MB 1.18
PoissonLDS/("smooth", "latent=4", "obs=10", "T=500") 17.4 k allocs: 3.6 MB 9.27 k allocs: 3.03 MB 1.19
PoissonLDS/("smooth", "latent=4", "obs=5", "T=100") 3.33 k allocs: 0.723 MB 2.06 k allocs: 0.618 MB 1.17
PoissonLDS/("smooth", "latent=4", "obs=5", "T=500") 17.4 k allocs: 3.58 MB 9.27 k allocs: 3.03 MB 1.18
time_to_load 0.149 k allocs: 11.2 kB 0.149 k allocs: 11.2 kB 1

There's no way that return was correct, surely it has to loop over all trials before returning. must have been a mistake, talk about stochastic update
Remove evidence of hacks that didn't work
@rsenne
rsenne self-requested a review August 13, 2026 17:54
@rsenne

rsenne commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

i will review this by EOD

@rsenne

rsenne commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

kluge regularization

wtf is this lol

Comment thread test/runtests.jl
Comment on lines +114 to +118
@skip_test test_SLDS_posterior_basic()
@skip_test test_SLDS_posterior_shapes()
@skip_test test_SLDS_posterior_deterministic_and_modes()
@skip_test test_SLDS_posterior_K1()
@skip_test test_SLDS_posterior_recovers_distinct_regimes()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably wanna unskip these?

z, x, y = rand(rng, slds, fill(tsteps, ntrials))

# Single-trial matrix ⇒ bare K × T matrix; wrapping in a vector ⇒ 1-element vector.
γ_mat = infer_γ(slds, y[1])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

many instances of this stale function handle

Comment thread docs/src/SLDS.md
the smoothed posterior mean), so the returned responsibilities are reproducible.

```@docs
infer_γ

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be updated

Comment thread src/lds/fit_SLDS.jl
end

# Core SLDS trial sampling logic
# Core SLDS trial sampling logic. `ux_trial` / `uy_trial` are the canonicalized

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems liek this last predicate was meant to be deleted?

Comment thread src/lds/fit_SLDS.jl
Comment on lines +402 to +404
`ws.opt.grad_buf` and returns it. `ux` (dynamics input, feeds `-Q⁻¹` /
`A'Q⁻¹` residuals via `Bₖ u`) and `uy` (observation input, feeds the emission
gradient via `Dₖ v`) are per-trial matrices; `nothing` or zero-row skips them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming claude added these types of statements. Probably can mention how inputs are done once and don't need to mention again unless the function is specific to them or has some behavior meant to truly notice

Comment thread src/lds/fit_SLDS.jl
Comment on lines +1382 to +1424
"""
posterior(slds, y; max_iter=100, tol=1e-6, check_convergence=true, progress=false)

Infer the discrete-state responsibilities `γₜ(k) = q(zₜ = k) ≈ p(zₜ = k ∣ y₁:T)`
of a **fitted** `SLDS`, holding all model parameters fixed.

This is a post-fit *inference* routine: it estimates the discrete posterior for a
model whose parameters are already learned, and does **not** update them (there is
no M-step).

It runs the structured-variational E-step, alternating between:

1. **discrete smoothing** — a forward-backward pass over the switching chain that
refreshes `q(z)` from the current per-regime log-likelihoods, and
2. **continuous smoothing** — the Laplace/Kalman smoother that refreshes `q(x)`
under the updated responsibilities `γ`,

until either the responsibilities converge or for a fixed number of iterations.

# Arguments
- `y`: observations — a `(obs_dim, T)` matrix (single trial), a
`(obs_dim, T, ntrials)` array, or a `Vector` of per-trial `(obs_dim, T_i)`
matrices (ragged trial lengths allowed).

# Keywords
- `max_iter::Int=100`: maximum number of E-step (forward-backward + smoother)
iterations.
- `tol::Real=1e-6`: convergence tolerance on `max|Δγ|`, the largest absolute change
in any responsibility between successive iterations (used only when
`check_convergence=true`).
- `check_convergence::Bool=true`: the mode flag. `true` iterates *until
convergence* (`max|Δγ| < tol`), capped at `max_iter` iterations; `false` runs
*exactly* `max_iter` iterations.
- `progress::Bool=false`: show a progress bar.

# Returns
The responsibilities `γ`:
- for a single-trial matrix `y`, a `K × T` matrix; and
- otherwise (3-D array or vector-of-matrices input), a `Vector` of per-trial
`K × T_i` matrices, one per trial.

Each column is a probability vector over the `K` discrete states (sums to 1).
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we de-claude this?

Comment thread src/lds/fit_SLDS.jl
function posterior(
slds::SLDS{T,S,O},
data::Data{T};
return_γ::Bool=true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if someone is calling posterior--is there really a circumstance in which they dont want gamma? I mean to an extent what sets this apart from smooth is really just the gammas

Comment thread src/lds/fit_SLDS.jl

# Returns
The responsibilities `γ`:
- for a single-trial matrix `y`, a `K × T` matrix; and

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think this is accurate

Comment thread src/lds/fit_SLDS.jl
return_x::Bool=false,
max_iter::Int=100,
tol::Real=1e-6,
check_convergence::Bool=true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also seems a bit unneccesary. I mean we probably should just check convergence and if we don't ust throw a warnign and return given the max_iters

Comment thread src/lds/fit_SLDS.jl
Comment on lines +1431 to +1432
return_elbo::Bool=true,
return_x::Bool=false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a bit of semantic dubiousness going on. we noe effecticely have two ways to get elbo right? so why should this function privilege the "good elbo" ? I wonder if this function need to be integrated more into the core code somewhat like in ssm -- e.g., the same function does the posterior, but depending on the calls, we wi8ther iterate until convergence or do it once etc.

@rsenne

rsenne commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

I think overall the functions are well written mopdulo some correctness issues. I do think the semantics need to be re-evaluated posterior is really just smoothing, so I think it might be more appropriate to get this machinery into smooth or a variant of it, and then make the public return both in a NamedTuple and then we can have the necessary kwargs to specify maxiters etc. thoughts?

@harrisonritz

Copy link
Copy Markdown
Collaborator Author

Ok, so maybe smooth just needs to have an smoothing_iters argument (as well as fit!/estep!, for that matter).

And then smooth can just return gamma, and the loglikelihood can return the elbo.

The only hiccup is that you might only want to run the back-and-forth estimator once -- because it might take many iterations to converge -- so nice to have it spit out the posterior and the elbo from the same fit. Maybe some caching would help here.

@rsenne

rsenne commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

smooth just needs to have an smoothing_iters

yeah that should work. In theory the smooth! variant can just default to 1 iteration to stay fiathul to OG vLEM algo

And then smooth can just return gamma

do you mean gamma and x? also i don't mind too much if return the elbo as part of a named tuple in smooth. my biggest concern is two places to compute the elbo and the one not called elbo is actually the more accurate one. we should probably also have some way in elbo to allow for the approximation to be better via more iters

@harrisonritz

Copy link
Copy Markdown
Collaborator Author

Ok, I'll take a look next week!

Meant smooth should now additionally return gamma (and elbo, good idea).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants