Skip to content

fix(sv): re-apply the per-variable log-vol level in the SV forecast (#241) - #268

Merged
thomaspinder merged 1 commit into
mainfrom
fix/241-sv-forecast-mu
Jul 29, 2026
Merged

thomaspinder merged 1 commit into
mainfrom
fix/241-sv-forecast-mu

Conversation

@thomaspinder

Copy link
Copy Markdown
Collaborator

Closes #241. Blocks the v0.0.9 release.

The bug

StochasticVolatility.build_pymc_latent composes the in-sample log-vol as

if dynamics.has_explicit_level:
    h_paths.append(h_i)              # AR(1): alpha carries the level
else:
    mu_i = pm.Normal(f"{prefix}mu", ...)
    h_paths.append(h_i + mu_i)       # RW: level lives OUTSIDE the path

so under random-walk dynamics h[..., i] == v{i}_h + v{i}_mu.

forecast_cholesky_path called dynamics.forecast_log_vol(..., name_prefix=f"v{i}_"), and RandomWalk.forecast_log_vol reads posterior[f"{prefix}h"] — the level-free path — and extrapolates from its last value. v{i}_mu was never re-applied.

Net effect: every multivariate SV forecast standard deviation came out at exp(-mu_i / 2) times the correct scale. The in-sample fit was unaffected, so nothing looked wrong. Every SV density, conditional and scenario forecast inherited the mis-scaled bands.

AR(1) was never affected: has_explicit_level is True, no outer mu_i is registered, and AR1.forecast_log_vol already reads alpha.

The fix

forecast_cholesky_path now branches on has_explicit_level exactly as the in-sample registration does — adding v{i}_mu back for the random-walk path, leaving AR(1) alone.

Why the tests missed it

synthetic_sv_idata_2v encoded the inverse of the true invariant:

h = rng.standard_normal(...) * 0.3 - 1.0
"v0_h": h[:, :, :, 0],                            # v_h == h
"v0_mu": rng.standard_normal((n_chains, n_draws)) # ...and mu != 0

That asserts mu_i == 0 while supplying a nonzero mu_i — precisely the assumption the buggy forecast made. Every test built on this fixture was structurally blind to the whole family of level bugs, and the existing forecast_cholesky_path tests only asserted shapes.

The fixture now respects h[..., i] == v{i}_h + v{i}_mu.

Tests

TestForecastLevelComposition, three pins:

Test Fails unfixed?
test_zero_innovation_forecast_continues_the_in_sample_level yes
test_level_is_not_double_counted yes
test_ar1_needs_no_outer_level no (unaffected branch, by design)

The first sets sigma_eta = 0, making the walk a flat continuation, so diag(L_t) must equal exp(h[-1] / 2) exactly — R_chol has a unit diagonal. Seed-independent and exact (rtol=1e-12), not a tolerance-tuned approximation.

Verified locally: 1049 passed, ruff check/ruff format/ty clean.

Out of scope

Companion issue #244 — v{i}_mu and the random walk’s own h0 are additively redundant, so the level is not separately identified, and SVDefaultPrior reuses mu_mu/mu_sigma (calibrated from the series mean/SD) for what is a log-variance level. Both are prior-specification questions; this PR only makes the forecast agree with the model that was fitted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

…241)

`build_pymc_latent` composes the in-sample log-vol as `v{i}_h + v{i}_mu`
whenever the dynamics carries no intrinsic level (`has_explicit_level`
False, i.e. random walk). `forecast_cholesky_path` extrapolated `v{i}_h`
alone, so every multivariate SV forecast standard deviation came out at
`exp(-mu_i / 2)` times the correct scale — silently, with the in-sample
fit unaffected. Every SV density, conditional and scenario forecast
inherited the mis-scaled bands.

`forecast_cholesky_path` now branches on `has_explicit_level` exactly as
the in-sample registration does, adding `v{i}_mu` back on for the
random-walk path and leaving AR(1) (whose `alpha` carries the level)
untouched.

The `synthetic_sv_idata_2v` fixture encoded the inverse of the true
invariant: it set `v{i}_h = h[..., i]` *and* a nonzero `v{i}_mu`, which
asserts `mu_i == 0` — precisely the assumption the bug made. Every test
built on it was structurally blind to this whole family of level bugs.
The fixture now respects `h[..., i] == v{i}_h + v{i}_mu`.

Three regression pins added; two of them fail on the unfixed code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.4%. Comparing base (19cb124) to head (777d098).

Additional details and impacted files
@@          Coverage Diff          @@
##            main    #268   +/-   ##
=====================================
  Coverage   95.4%   95.4%           
=====================================
  Files         46      46           
  Lines       3434    3438    +4     
  Branches     439     440    +1     
=====================================
+ Hits        3278    3282    +4     
  Misses       111     111           
  Partials      45      45           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thomaspinder
thomaspinder merged commit e9ec327 into main Jul 29, 2026
9 checks passed
@thomaspinder thomaspinder added the breaking-change Breaking changes label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multivariate SV forecast drops the per-variable log-vol level mu_i

2 participants