Skip to content

feat(identification): max-share frequency-band identification - #200

Draft
thomaspinder wants to merge 7 commits into
mainfrom
feat/145-max-share-identification
Draft

feat(identification): max-share frequency-band identification#200
thomaspinder wants to merge 7 commits into
mainfrom
feat/145-max-share-identification

Conversation

@thomaspinder

Copy link
Copy Markdown
Owner

Summary

New MaxShare identification scheme: per posterior draw, find the structural shock explaining the largest share of a target variable's variance over a user-selected frequency band, returned through the standard IdentifiedVAR pipeline.

  • Math: the band objective M = ∫_B (C_i(e^{-iω})L)*(C_i(e^{-iω})L) dω is Hermitian PSD; for real rotations the maximiser is the top eigenvector of Re(M)exact, not an approximation (the two-sided band integral is 2·Re(M)). Implemented via the factorisation M = Lᵀ Re(K) L where the frequency accumulator K depends only on the posterior coefficients — computed once and cached, so per-L work (including the stochastic-volatility per-period path) is a cheap batched eigendecomposition. Row extraction uses the plain-transpose solve Fᵀx = eᵢ (the subtlest point — independently re-derived and numerically confirmed by the reviewer).
  • User-facing convention: band=(low, high) in periods of the sampling interval (business cycle = (6, 32) quarters; high=inf reaches the zero-frequency limit for low-frequency climate bands, safe under the midpoint quadrature which never evaluates ω=0). Nyquist-validated.
  • Partial identification done properly: the single identified column (sign-normalised to a positive target impact) is completed to a full rotation via a Householder reflection; remaining shocks are labelled unidentified_*, flowing through the existing FEVD masking and historical-decomposition remainder collapse unchanged.
  • Failure separation (following the feat(identification): long-run (Blanchard-Quah) structural restrictions #183 convention): numerically singular transfer functions at band frequencies are screened by condition number and NaN'd (or raise, via on_undefined); explosive draws warn-and-keep (the factorisation is exact; only the variance interpretation weakens). Diagnostics — achieved share quantiles, top-2 eigenvalue-ratio (weak-identification indicator, warned at >0.9), singular/explosive counts — ride shock_matrix().attrs and a public max_share_diagnostics().
  • Includes the same NaN-preserving FEVD _shares fix as PR feat(identification): long-run (Blanchard-Quah) structural restrictions #183 (copied byte-verbatim so the branches auto-merge; reviewer confirmed byte-equality). Docs: explanation section with labelled equations, a how-to with business-cycle and climate examples plus an explicit "variance dominance is not causal identification" admonition, reference entry, CONTEXT.md term, three bib entries.

Closes #145

Review

Planned by a Fable-tier planning agent that verified the math numerically before design (exact analytic recovery; eigen-solution beating a 1024-point rotation grid; Parseval to 5e-16). Independently reviewed with the row-extraction identity and K-accumulator re-derived from scratch and validated on a VAR(2) against an 8193-point trapezoid reference with explicit inversion. Two mechanical P2s fixed in a follow-up commit — including new VAR(2) tests whose necessity was mutation-verified: a reversed lag-order bug invisible to the entire VAR(1) suite fails exactly the three new tests.

Tests

+50 tests: exact analytic recovery on a single-driver DGP (share = 1.0, impact column recovered to machine precision, deliberately non-triangular so the test discriminates from Cholesky); brute-force optimality against an independent quadrature and a 200k-direction sphere sweep; Parseval/FEVD flow-through (frequency-domain share equals time-domain FEVD share to 1e-16); VAR(2) multi-lag pins; singular/explosive/weak-identification screens; full pipeline composition (IRF/FEVD/HD with unidentified_* handling); NaN-draw FEVD preservation.

ruff / ty / fast suite: all green (577 passed, 29 deselected). New docs content renders clean (the only docs-CI warnings are pre-existing stale stubs from other branches' builds, identical on a clean tree).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.18310% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.1%. Comparing base (866ca8f) to head (7cac559).

Files with missing lines Patch % Lines
src/impulso/identification.py 96.7% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##            main    #200    +/-   ##
======================================
  Coverage   95.0%   95.1%            
======================================
  Files         45      45            
  Lines       3099    3240   +141     
  Branches     380     397    +17     
======================================
+ Hits        2945    3082   +137     
- Misses       111     113     +2     
- Partials      43      45     +2     

☔ 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 and others added 6 commits July 29, 2026 23:09
…145)

Two rank-agnostic helpers that maximum-share identification needs and
that other schemes will want too.

`companion_spectral_radius` separates the *interpretation* failure
(explosive draw — the spectral density is not that of a stationary
process) from the arithmetic one, so a scheme can report the two
independently instead of blanking both.

`householder_from_e1` lifts the orthogonal-completion construction that
partial-identification schemes use to turn one identified column into a
full invertible `P` with `P P' = Sigma`. ProxySVAR keeps its inline copy
for now; deduplicating it is a separate change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
Identifies the single structural shock explaining the largest share of
one variable's variance over a stated frequency band — the main
business-cycle shock of Angeletos, Collard and Dellas (2020), in the
frequency-domain form of Faust (1998) and Uhlig (2004).

Nothing is searched for. Writing the band variance of a candidate column
`p = L q` as `q' M q` with `M = int_B (C_i L)^* (C_i L) dw`, the
maximiser is the leading eigenvector of the real symmetric `Re(M)` and
the achieved share is `lambda_max / trace`. `M` factors as `L' Re(K) L`
where `K` depends only on the posterior, the target and the band — so
the frequency sweep is memoised and each `identify()` costs one triple
product and one `eigh`, which keeps the per-period loop under
time-varying volatility linear rather than quadratic.

Design notes:

- The band is stated in *periods of the sampling interval*, not radians:
  quarterly business cycles are `band=(6, 32)`. Radians in the API would
  be a needless translation step at every call site.
- Quadrature is a uniform midpoint rule, which never lands on `w = 0` and
  so makes `high_period=inf` (the low-frequency band) safe.
- One column is identified; the rest are an orthogonal completion
  labelled `unidentified_*`, so the existing FEVD masking and historical
  decomposition remainder collapse apply unchanged.
- Rows stay in the data's own variable order by construction — there is
  no ordering parameter, so the permutation bug class cannot arise.
- Explosive draws are warned about and kept (blanking them would
  condition the posterior on stability); draws whose transfer function is
  numerically singular *inside the band* are blanked, since that is an
  arithmetic failure rather than an interpretive one.
- A near-repeated top eigenvalue means the maximiser is a plane rather
  than a ray, so the returned rotation within it is arbitrary. That is
  surfaced as a diagnostic and a warning, not silently fixed.

Tests pin exact analytic recovery on a fixture built backwards from the
answer, optimality against a brute-force grid over an independently
computed spectral density, and the Parseval identity that the full-band
share equals the infinite-horizon FEVD share.

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

Explains the scheme as a quadratic form whose maximiser is an
eigenvector, documents the periods-not-radians band convention with a
frequency conversion table, and gives the how-to a climate example
alongside the business-cycle one.

Both pages carry the same warning in different words: a band organises
variance, it does not name a mechanism. The scheme finds a direction in
shock space; whether that direction deserves an economic name is an
argument the user has to make elsewhere.

The class docstring cites in prose rather than with `{cite:t}` — no
docstring in the codebase uses the role, and the reference stubs are not
processed by sphinxcontrib-bibtex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
…#145)

Every MaxShare test was VAR(1), where every lag exponent collapses to
`e^{-i w}` and the lag index is unobservable. A reversed lag order — a
mutation invisible to the entire previous suite — now fails three of the
new tests; starting the exponent at `j = 0` fails nine.

The test-local reference is generalised to `p` lags and builds the sum
term by term with an explicit `e^{-i w j}`, still inverting `F(w)`
outright rather than solving, so it shares no code path with the
implementation. On a stable 3-variable VAR(2) the reported band share is
0.990355098 against 0.990355117 from an 8193-point trapezoid, and beats
200k directions sampled on the sphere.

Also documents that `_last_diagnostics` is overwritten per time slice, so
the attrs on `shock_matrix(at="all")` describe the last slice only —
which matters for the two L-dependent diagnostics and for nothing else,
since the screens are properties of the posterior alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
`MaxShare._spectral_cache` keyed its memoised frequency sweep on
`id(posterior)`. An address is not a validity token: once the posterior
is collected the address can be reused, and a lookup with a different
posterior that lands on it returns another model's accumulator `K`,
singular mask and spectral radii silently.

Swap it for the weakref-validated `_PosteriorCache` helper added in
dead or different referent as a miss. Semantics are unchanged: the
sweep still computes once per (posterior, n_lags, target_index), and
`_report` still fires its singular/explosive warnings only on a miss,
so the per-t identification loop warns once rather than T times.
`_warn_weakly_identified` is untouched — the eigenvalue ratio depends
on `L`, so it is legitimately evaluated on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Egjd7ToFeb9TQqFnfRQZxV
@thomaspinder
thomaspinder force-pushed the feat/145-max-share-identification branch from f06fbe5 to 5d5d731 Compare July 29, 2026 21:10
…base conflict

The rebase conflict resolution merged the new bibliography entry into the
blanchardQuah1989 entry, dropping its closing brace. sphinxcontrib-bibtex
then failed to parse references.bib, breaking build-docs and docs-linkcheck.
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.

feat: add max-share frequency-band identification

2 participants