The spectral state#
Sixteen amplitude paths and sixteen frequency paths are a lot to read,
and a claim such as “the spectrum compressed in 2020” is a claim about
all of them at once. SpectralState
reduces the decomposition at each step to a probability measure on the
frequency axis and reports the coordinates of that measure: where it
sits, how spread it is, how many components it effectively uses, how
fast it moves, and how well the components can be told apart. The
coordinates are diagnostics of a model trained on the prediction
criterion alone; nothing here enters a loss.
The measure#
At row \(t\) component \(k\) contributes with amplitude
\(a_{t,k} = |w_k|\, g_{t,k} A_{t,k}\) (effective_amplitude) at
frequency \(\omega_{t,k}\). Its power is \(p_{t,k} =
a_{t,k}^2\), and normalizing gives a discrete measure on the frequency
axis,
weight is \(\pi\), shape (r, k); frequency is the
support. Every coordinate below is a functional of \(\mu_t\).
Position and spread#
centroid is the power-weighted mean frequency, dispersion the
power-weighted standard deviation. A falling centroid is the spectrum
slowing down; a falling dispersion is the components converging in
frequency.
Effective count#
entropy is the spectral entropy, effective_count its exponential
(between 1 and \(K\): the number of equally weighted components
that would have the same entropy), and participation_ratio the
inverse participation ratio, a second effective count that weights the
large components more. Both fall when power concentrates in fewer
components.
Compression, in these terms, is a joint contraction of dispersion
and effective_count: the components move together in frequency and
fewer of them carry the power. Either alone is a different story, and
the IEEE-ORSS reading of 2020, frequency convergence with amplitude
concentration, is the first.
Velocity#
How fast the measure moves between consecutive rows is the 1-Wasserstein distance [Villani, 2009] per unit time,
computed in closed form from the two cumulative distribution functions.
velocity has one entry fewer than the rows, since it sits between
them. Two decompositions of the same step distinguish why the measure
moved: velocity_frequency is \(W_1\) between the measures at
\(t\) and \(t - 1\) with the weights frozen at \(t - 1\)
(the components moved), and velocity_mass with the frequencies
frozen at \(t - 1\) (the power moved between components). They do
not add exactly to velocity, but their relative size says whether a
spike in it was a shift in frequencies or a reallocation of power.
Active set and disentanglement#
A component whose weight is tiny is unidentified: its frequency, phase
and offset are not pinned down by the data because it contributes
nothing to the emission. active marks, per row and component,
whether \(\pi_{t,k}\) is at least active_threshold (default
0.01), and per-component readings should be restricted to active rows.
contribution_correlation is the \(K \times K\) correlation
matrix of the components paths over the sample, and
disentanglement_score is one minus the off-diagonal share of its
squared entries, in \([0, 1]\): one when the contributions are
mutually uncorrelated, falling as components come to carry the same
movement. The measure alone cannot tell converged frequencies from
redundant components, and this score is the check: identifiability
degrades exactly as \(\omega_k \to \omega_l\), so the compression
regime is also the regime in which per-component readings are least
trustworthy. State that caveat with any compression claim rather than
beside it.
Computing it#
spectral_state() on a result, or
SpectralState.from_result:
import numpy as np
from ns_sdn.spectral_decomposition import NSSDN, Stage
t = np.arange(240.0)
y = np.sin(0.3 * t) + 0.5 * np.sin(0.07 * t)
res = NSSDN(y, k=3, state_dim=8, schedule=(Stage(1e-2, 40),), seed=0).fit()
state = res.spectral_state()
print(state.nobs, state.k)
print(state.weight.shape, state.centroid.shape, state.velocity.shape, state.active.shape)
print(bool(np.allclose(state.weight.sum(axis=1), 1.0)))
print(bool(np.all(state.effective_count >= 1.0)), bool(np.all(state.effective_count <= 3.0)))
print(0.0 <= state.disentanglement_score <= 1.0)
240 3
(240, 3) (240,) (239,) (240, 3)
True
True True
True
summary() prints the min, mean and max of each scalar coordinate
with the disentanglement score and the mean number of active components
in the header; to_dict() and to_pandas(index=...) lay the scalar
coordinates out one row per path row, velocities padded with nan in
the first row so the frames align:
['centroid', 'dispersion', 'effective_count', 'entropy', 'n_active', 'participation_ratio', 'velocity', 'velocity_frequency', 'velocity_mass']
True (240,)
Testing a regime claim#
A regime-change claim in these coordinates is a before-and-after
comparison. Mark the event on the result’s time, and compare the
coordinates over a window on each side:
centroid (40,) (40,)
dispersion (40,) (40,)
effective_count (40,) (40,)
What makes the comparison evidence is that it holds across refits and
across k: the coordinates are invariant to the permutation of
components, which the raw paths are not, so a seed sweep over the same
specification is the natural check. The
DGS10 example
draws the coordinates with the 2020 window marked.