Skip to main content
ns-sdn Logo

A neural state-space model that decomposes a non-stationary time series into a trend and a few sinusoids whose amplitude, frequency and participation move with a latent state, and reads the decomposition back as coordinates.

CI / Quality Analyze Tests Docs Ruff
Security CodeQL OpenSSF Scorecard Socket
Coverage Coverage
Packaging uv Hatch project
Distribution PyPI Python Versions
License License
Usage PyPI Downloads
Research / Index DOI FLAIRS-39 IEEE ORSS

Stable. ns-sdn 1.0.0 is the first stable release. The public names, argument names, result fields and summary wording are frozen for the 1.x line under semantic versioning, the defaults reproduce the IEEE-ORSS run to the parameter count, and a change that alters the output of the default configuration on the published data requires a major version. Pin the exact version in anything that must reproduce.

Research context

Note: Listing does not imply endorsement or affiliation.

ns-sdn is developed as fellowship research under the ICSRI Fellows Program at the Intelligent Computer Systems Research Institute, Miami Herbert Business School, University of Miami. For that reason the project does not accept outside code contributions; see Contributing.

University of Miami Herbert Business School

What it is

At each step the network reads a latent state and emits a trend plus $K$ sinusoids,

$$ \hat y_t = B_t + \sum_{k=1}^{K} w_k, g_{t,k}, A_{t,k}, \sin!\bigl(\theta_{t,k} + \phi_{t,k}\bigr), \qquad \theta_{t,k} = \theta_{t-1,k} + \omega_{t,k},\Delta t, \qquad h_t = \mathrm{GRU}(h_{t-1}, y_t), $$

where $B_t$ is the trend, $A_{t,k}$ the amplitude, $\omega_{t,k}$ the instantaneous frequency, $\phi_{t,k}$ the phase offset and $g_{t,k}$ the gate of component $k$, each a linear head on the state, and $w$ a learned mixing vector. The phase integrates the frequency, so each component is a chirp the state can steer. The network is trained as a one-step predictor with drift penalties on the amplitudes and frequencies, and read as a time-varying spectral decomposition.

The package implements the model of the IEEE-ORSS paper (Sunder and Leon, 2026) and the architecture introduced at FLAIRS-39 (Sunder, 2026), in the model-fit-result pattern of cultivars: a validated specification, one fit(), and a frozen result that carries every path of the decomposition on the original scale and prints a summary that states what its numbers assume.

Module Contents
ns_sdn.spectral_decomposition NSSDN, the specification; NSSDNResult, the fitted record with the trend, amplitude, frequency, phase, offset, gate, component and state paths, one-step predict, recursive forecast, decompose on new data; Stage and Penalties, the schedule
ns_sdn.spectral_state SpectralState: the decomposition as a time-local measure on the frequency axis, with centroid, dispersion, entropy, effective count, participation ratio, Wasserstein velocity split into frequency and mass motion, active set, disentanglement score
ns_sdn.summary SummaryTable and InformationCriteria, the records every result renders itself through
ns_sdn.typing The categorical option aliases: Alignment, Transition, Objective, Scaling
ns_sdn.exceptions DimensionError, SpecificationError, NumericalError, one per kind of fix, under NSSDNError

Dependencies are numpy and torch. pandas (dataframe export), fedfred (FRED access for the examples) and matplotlib (figures in the examples) are optional extras.

Installation

Requires Python 3.12 or later.

pip install ns-sdn

With the extras:

pip install "ns-sdn[pandas,data]"     # dataframes and FRED access
pip install "ns-sdn[all]"             # plus matplotlib

With uv:

uv add ns-sdn

On Linux, PyPI's torch wheel depends on the CUDA libraries, several gigabytes that a CPU-only machine never loads. Install torch from the CPU index first and ns-sdn will use it:

pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install ns-sdn

For a reproducible research environment, pin the exact version: ns-sdn==1.0.0. A conda-forge package follows this release.

Quick start

Fit a decomposition

A series with a drifting level, a chirp and a fast oscillation, and a three-component model on a short schedule so the example runs in seconds. The published configuration is the default (NSSDN(y): sixteen components, a 128-dimensional state, the two-stage schedule).

import numpy as np
from ns_sdn.spectral_decomposition import NSSDN, Stage

rng = np.random.default_rng(3)
t = np.arange(300.0)
y = 2.0 + 0.003 * t + np.sin(np.cumsum(0.10 + 0.0003 * t)) + 0.4 * np.sin(0.55 * t)
y += 0.05 * rng.standard_normal(300)
train, test = y[:270], y[270:]

res = NSSDN(train, k=3, state_dim=16, schedule=(Stage(1e-2, 80),), seed=0).fit()
print(res)
                                       NSSDN(k=3) Results
================================================================================================
Model:          NSSDN(k=3, d=16, gru, filtered)  Log-likelihood:                          47.550
Objective:                                  mse  AIC:                                   2214.901
Scaling:                                 minmax  BIC:                                   6366.792
Observations:                               269  HQIC:                                  3882.309
Parameters:                                1155  One-step RMSE:                         0.202766
Gating:                                    True  Emission RMSE:                        0.0318907
------------------------------------------------------------------------------------------------
                mean amp   mean freq   period   mean gate      mix   power share
component 1       0.1031      0.4796     13.1       0.189   +0.241         0.945
component 2       0.2300      0.8186      7.7       0.365   +0.017         0.021
component 3       0.1594      0.7729      8.1       0.250   -0.030         0.033
================================================================================================
Filtered alignment: the emission at t conditions on y[t], so emission is a reconstruction of the
observed series and fittedvalues report the emission at t as the one-step forecast of y[t+1]
(the published protocol); compare against a random walk before reading them as skill.
llf is a concentrated Gaussian likelihood at a penalized fit and n_params the nominal weight
count; criteria compare k within one configuration only.
Components are identified up to permutation; indices are not stable across refits.

Read the paths

Every path is an array on the result, on the original scale of the series, and the synthesis equation is an identity on them:

res.trend.shape, res.amplitude.shape, res.frequency.shape, res.gate.shape   # (270,), (270, 3), (270, 3), (270, 3)
np.allclose(res.trend + res.components.sum(axis=1), res.emission)            # True
res.period.mean(axis=0)                                                      # 2*pi / frequency, in time units
res.decompose(scaled=True)                                                   # the same paths in training units, as the papers draw them
res.decompose(other_series)                                                  # the trained network over new data
res.to_pandas(index=dates)                                                   # one column per path, if pandas is installed

Forecast two ways

The published evaluation is conditional one-step: run the trained network over training and holdout together under teacher forcing and read the emission at each step as the forecast of the next. The random walk is the benchmark to beat.

one_step = res.predict(y)[-30:]            # one value per observation after the first; the last 30 are the holdout
naive = y[269:-1]                          # y[t-1] as the forecast of y[t]
rmse = lambda a, b: float(np.sqrt(np.mean((a - b) ** 2)))
rmse(test, one_step), rmse(test, naive)    # 0.245, 0.223 on this short fit: persistence wins

path = res.forecast(10)                    # recursive: each emission fed back as the next input

Read the spectral state

Sixteen amplitude and frequency paths are a lot to reason about. spectral_state() normalizes each row's contribution powers into a probability measure on the frequency axis and reports its coordinates:

state = res.spectral_state()
print(state)
                              Spectral state (k=3)
================================================================================
Rows:                              270  Disentanglement:                 0.988
Active threshold:                 0.01  Mean active:                      3.00
--------------------------------------------------------------------------------
coordinate                              min / mean / max
centroid                         0.4829 / 0.4981 / 0.578
dispersion                   0.04368 / 0.07437 / 0.08533
effective count                    1.207 / 1.301 / 1.402
participation ratio                 1.082 / 1.127 / 1.18
velocity                  8.892e-05 / 0.002766 / 0.04415
velocity (frequency)      5.351e-05 / 0.002124 / 0.04279
velocity (mass)          1.417e-05 / 0.0006716 / 0.00206
================================================================================
Diagnostics of a model trained on the prediction criterion alone; nothing here
enters a loss.
Identifiability degrades as frequencies converge: compression is also the regime
of weakest disentanglement, and per-component readings of inactive rows are not
meaningful.

Every coordinate is also an array on the state, one entry per row. A regime claim, such as the frequency convergence with amplitude concentration that the IEEE-ORSS paper reads around 2020, is a joint contraction of dispersion and effective_count across a window, in coordinates that survive a refit; the component indices do not.

Reproduce the paper

FRED_API_KEY=... python examples/plot_03_reproduce_orss.py

fetches log DGS10 on the papers' daily grid, fits the default configuration with seed 1337, and writes the metrics table (with the random-walk rows the paper did not include), the six figures and the spectral state to orss_reproduction/.

Design

Three commitments run through the package.

The published run is the contract. NSSDN() with no arguments is the IEEE-ORSS configuration, the test suite pins its 58,849 parameters, and every option that was not in the paper (ordered_frequency, the predicted alignment, the timeaware transition, the extra penalties) is off by default and named in the summary when on.

The decomposition is the product, and it says what it is. The result carries every path the papers' figures are drawn from and the identity between them. The summary states which alignment produced it and what that makes of its one-step numbers: under the default filtered alignment the emission at $t$ has seen $y_t$, so it is a reconstruction, and the one-step forecast is that emission carried forward, to be read against a random walk before being read as skill.

Failures say what kind of fix is needed. Every exception derives from ns_sdn.exceptions.NSSDNError and from one of DimensionError (reshape the data), SpecificationError (change the choice), NumericalError (look at the numbers). Each also derives from the matching builtin, and the hierarchy mirrors cultivars.exceptions, so one except clause handles both libraries.

Packages re-export modules rather than classes; import the model from the module that defines it, as above.

Documentation

The user guide derives the model and explains how to read each path, the two forecast protocols and the spectral-state coordinates; the nuances page lists what the model does that a reader of the papers may not expect (the trend head absorbing the series on short schedules, the calendar-fill frequency artifact, permutation identifiability). The API reference is generated from the docstrings, every one of which carries a runnable example; the examples double as the doctest suite.

Development

git clone https://github.com/nikhilxsunder/ns-sdn.git
cd ns-sdn
uv sync --group dev
uv run pytest                                            # doctests + tests, with coverage
uv run ruff check src/ns_sdn tests examples
uv run ruff format --check src/ns_sdn tests examples
uv run mypy
uv run make -C docs html                                 # documentation, running the examples

Contributing

ns-sdn is fellowship research and is assessed as the work of the fellow, so the project does not accept outside code contributions for the duration of the research; unsolicited pull requests are closed without review. Bug reports, reproduction reports, feature requests, documentation corrections and security reports are welcome and acted on, with credit in the changelog. CONTRIBUTING.md has the policy and the maintainer's conventions, and SECURITY.md the private channel for vulnerabilities.

Citation

If ns-sdn contributes to published work, cite the IEEE-ORSS paper the package implements, the FLAIRS paper that introduced the architecture, and the software:

@inproceedings{sunder2026orss,
  title     = {Non-Stationary Spectral Decomposition Network: Adaptive Spectral Emission Heads and Frequency Modulation},
  author    = {Sunder, Nikhil and Leon, Maikel},
  booktitle = {2026 IEEE Opportunity Research Scholars Symposium (ORSS)},
  address   = {Atlanta, GA, USA},
  publisher = {IEEE},
  pages     = {1--5},
  year      = {2026},
  doi       = {10.1109/ORSS71174.2026.11684282}
}

@article{sunder2026flairs,
  title   = {Non-Stationary Spectral Decomposition Network for Econometric Time Series Forecasting},
  author  = {Sunder, Nikhil},
  journal = {The International FLAIRS Conference Proceedings},
  volume  = {39},
  number  = {1},
  year    = {2026},
  doi     = {10.32473/flairs.39.1.141588}
}

@software{sunder_nssdn,
  author    = {Sunder, Nikhil},
  title     = {ns-sdn: the Non-Stationary Spectral Decomposition Network},
  year      = {2026},
  publisher = {Zenodo},
  doi       = {10.5281/zenodo.23368625},
  url       = {https://github.com/nikhilxsunder/ns-sdn}
}

The software DOI is the Zenodo concept DOI, which resolves to the latest release; every release is archived under its own DOI, listed on the Zenodo record. To pin a version in a citation, add version = {1.0.0} and cite that release's DOI. CITATION.cff carries the same entries in a form GitHub and Zenodo read directly.

License

MIT. See LICENSE.

Metadata

Release files for ns-sdn 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ns-sdn 1.0.0
File Size Uploaded Provenance
ns_sdn-1.0.0.tar.gz 302.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ns-sdn 1.0.0
File Interpreter ABI Platform
ns_sdn-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 395.8 kB

Release files / ns_sdn-1.0.0.tar.gz

Download URL ns_sdn-1.0.0.tar.gz
Size 302.7 kB
Tags Source
SHA-256 checksum
How to use checksums
49304e4e34824706acd2224f84c7ab19540af2e8df8354272eeb150fc2c653e6
BLAKE2b-256 checksum
How to use checksums
8fe10cec7fe09a220e7959366058174ffe96b27d7e5cd98b9e825a09b3c3ba7c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / ns_sdn-1.0.0-py3-none-any.whl

Download URL ns_sdn-1.0.0-py3-none-any.whl
Size 93.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9e41842d85708f5a1489834eaa8df42dfec1f67c24f4805298461828a38c699c
BLAKE2b-256 checksum
How to use checksums
2879a876670e014559b2199b3939870d540b7bbc2972d19c627e8f7f7110ad96
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for ns-sdn 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page