cultivars#

Research-grade time series econometrics for Python. ARIMA to state space, VAR to structural identification, Bayesian and spectral methods, on numpy and scipy alone, behind one estimation surface: construct a model with the data and its specification, call fit(), read a result that states its verdict and the assumptions behind it.

Getting started

Install, fetch a series, fit a first model, read its summary.

Getting started
User guide

The mathematics, the models, the decisions, family by family.

User guide
Examples

Research workflows on live FRED data, as scripts and notebooks.

Examples
API reference

Every public class, function and result, with its sources.

API reference

Release status#

cultivars 1.0.0a4 is a pre-release

API: stable in shape, not frozen numerics: settling

The model-fit-result pattern, the subpackage layout and the names of the public classes will not change before 1.0.0. Argument names, result fields and summary wording may still be renamed; each rename is listed in the release notes. The public API freezes at 1.0.0b1, after which only fixes land. Pin the exact version in anything that must not move:

pip install --pre "cultivars==1.0.0a4"

Install#

Python 3.12 or newer. The core depends on numpy, scipy and fedfred; pandas and polars are optional and switch on dataframe input and to_pandas() / to_polars() on every result.

pip install --pre cultivars
pip install --pre "cultivars[pandas]"      # or [polars]
uv add --prerelease allow cultivars
uv add --prerelease allow "cultivars[pandas]"
git clone https://github.com/nikhilxsunder/cultivars.git
cd cultivars
uv sync --group dev

A conda-forge package follows the first stable release.

In sixty seconds#

Every tool in the library follows the same three lines. The example is synthetic so it runs without a network connection; the examples do the same on FRED data.

import numpy as np
from cultivars.multivariate.reduced_form.vector_autoregression import VAR
from cultivars.multivariate.structural.zero_restrictions import RecursiveSVAR

rng = np.random.default_rng(0)
A = np.array([[0.5, 0.3], [0.0, 0.4]])          # rate feeds gdp, not the reverse
y = np.zeros((300, 2))
for t in range(1, 300):
    y[t] = A @ y[t - 1] + rng.standard_normal(2)

var = VAR(y, order=1, names=("gdp", "rate")).fit()           # 1. model, fit
svar = RecursiveSVAR(var, order=("gdp", "rate")).identify()  # 2. identify
irf = svar.irf(horizon=8)                                    # 3. read the result

print(var.is_stable, svar.scheme, irf.shape)
print(var.granger_causality("rate", "gdp").summary())
True recursive (9, 2, 2)
                                   Wald Test
================================================================================
Verdict at 5%:                  reject
--------------------------------------------------------------------------------
null                                statistic   df   p-value
rate does not Granger-cause gdp       15.9250    1    0.0001
================================================================================

print(result) renders a summary table: the estimates, a verdict where there is one, and the notes that qualify it. A result is a frozen dataclass, so everything in the table is also a field, and to_pandas() returns the coefficient table as a frame when pandas is installed.

What is inside#

Seven subpackages, 74 modules, roughly one hundred model classes and forty test functions. The subpackages hold modules only; every public name is imported from the module that defines it, and the API reference is organised the same way.

ARIMA and seasonal ARIMA, AR, GARCH, GJR, EGARCH, FIGARCH, stochastic volatility and UC-SV, Markov switching, threshold and smooth-transition autoregressions, ARFIMA, unobserved components.

cultivars.univariate

VAR, VARX, VECM, VARMA, panel, global, functional and mixed-frequency VARs, dynamic Nelson-Siegel; zero, sign, narrative, set, proxy, heteroskedasticity and non-Gaussian identification; large Bayesian VARs, dynamic factor models; Markov-switching, threshold, smooth-transition, time-varying and quantile VARs.

cultivars.multivariate

The linear Gaussian representation with Kalman filter and smoother, extended, unscented and particle filters for nonlinear models, Kim’s filter and smoother for switching systems. Every state-space model in the library exposes its representation through state_space.

cultivars.state_space

Minnesota, Normal-inverse-Wishart, independent Normal-Wishart, sum-of-coefficients, dummy-initial-observation, horseshoe, Dirichlet-Laplace, normal-gamma and spike-and-slab priors; split-R-hat and effective sample sizes; marginal likelihood by bridge sampling and modified harmonic mean; prior and posterior predictive checks; model averaging and stacking.

cultivars.bayes

Unit roots (ADF, Phillips-Perron, KPSS, DF-GLS, Ng-Perron, Zivot-Andrews), Johansen cointegration rank with simulated p-values, structural breaks (sup-Wald, Bai-Perron, CUSUM), nonlinearity (Tsay, Terasvirta, BDS, Hansen), seasonality (HEGY, Canova-Hansen), long memory (GPH, local Whittle).

cultivars.diagnostics

A rolling and expanding backtest harness that keeps point forecasts and predictive draws aligned with outcomes; Diebold-Mariano with the Harvey-Leybourne-Newbold correction, Giacomini-White, Clark-West, encompassing and Mincer-Zarnowitz tests; the model confidence set; PIT calibration and Berkowitz; CRPS, log and energy scores; conditional forecasts and fan charts.

cultivars.forecast

Periodogram, Daniell, Welch and multitaper spectra; the spectral density implied by a fitted system; Hodrick-Prescott, Baxter-King, Christiano-Fitzgerald, Hamilton, Butterworth and Beveridge-Nelson decompositions; Bry-Boschan turning points and concordance; wavelets; spectral Granger causality.

cultivars.spectral

A worked analysis#

The library is built for the workflow of an empirical macro paper, so the representative example is one. It pulls real GDP, the GDP deflator, the federal funds rate and the NBER recession indicator from FRED with fedfred (pip install fedfred; a free key from fredaccount.stlouisfed.org, read from the FRED_API_KEY environment variable), then runs one tool from each subpackage on the result.

import numpy as np
import pandas as pd
import fedfred as fd

from cultivars.diagnostics.unit_roots import adf, kpss
from cultivars.univariate.box_jenkins import ARIMA
from cultivars.multivariate.reduced_form.vector_autoregression import VAR
from cultivars.multivariate.structural.zero_restrictions import RecursiveSVAR
from cultivars.spectral.cycles import TurningPoints, concordance
from cultivars.spectral.density import SpectralDensity
from cultivars.spectral.filters import HamiltonFilter
from cultivars.spectral.periodogram import MultitaperSpectrum

fred = fd.FredAPI(cache_mode=True)              # key from FRED_API_KEY

def fetch(series_id: str, **kwargs) -> pd.Series:
    frame = fred.get_series_observations(series_id, observation_start="1960-01-01", **kwargs)
    return frame["value"].astype(float).rename(series_id)

gdp = fetch("GDPC1")                                               # real GDP, quarterly
deflator = fetch("GDPDEF")                                         # GDP deflator
ffr = fetch("FEDFUNDS", frequency="q", aggregation_method="avg")   # monthly -> quarterly mean
rec = fetch("USREC", frequency="q", aggregation_method="avg")      # share of months in recession

log_gdp = np.log(gdp).dropna()
growth = (400 * log_gdp.diff()).dropna()                           # annualised percent
inflation = (400 * np.log(deflator).diff()).dropna()
panel = pd.concat({"growth": growth, "inflation": inflation, "ffr": ffr}, axis=1).dropna()

# 1. Integration order. The two tests have opposite nulls; agreement is the evidence.
print(adf(log_gdp.to_numpy(), trend="ct").summary())
print(kpss(growth.to_numpy(), trend="c").summary())

# 2. A univariate model of growth.
arma = ARIMA(growth.to_numpy(), order=(2, 0, 1)).fit()
print(arma.summary())

# 3. Trend and cycle, one-sided and never revised, then a Bry-Boschan chronology against NBER.
cycle = HamiltonFilter(horizon=8, lags=4).filter(log_gdp.to_numpy())
turns = TurningPoints(convention="quarterly").date(log_gdp.to_numpy())
nber = rec.reindex(log_gdp.index).ffill().fillna(0).to_numpy() >= 0.5
print(turns.summary())
print("concordance with NBER:", round(concordance(turns, nber), 3))

# 4. A VAR, a Granger test, a recursive SVAR and its impulse responses.
var = VAR(panel.to_numpy(), order=4, names=tuple(panel.columns)).fit()
print(var.granger_causality("ffr", "growth").summary())
svar = RecursiveSVAR(var, order=("growth", "inflation", "ffr")).identify()
irf = svar.irf(horizon=20)                                         # (horizon + 1, variable, shock)
print(pd.DataFrame(irf[:, :, 2], columns=panel.columns).round(3).head(8))
print(pd.DataFrame(var.forecast(steps=8), columns=panel.columns).round(2))

# 5. Does the VAR reproduce the data's spectrum at business-cycle frequencies?
data_spectrum = MultitaperSpectrum(panel["growth"].to_numpy(), bandwidth=4.0).compute()
model_spectrum = SpectralDensity(var).compute()
print(data_spectrum.summary())

Four things the example is showing on purpose. The unit-root tests have opposite nulls, and agreement between them is the evidence, not either one alone. HamiltonFilter is one-sided and never revised, which is what makes it usable for dating in real time; HodrickPrescottFilter is there for comparability with the literature and its summary says why to prefer the former for inference. RecursiveSVAR states the Cholesky ordering as an identifying assumption in its summary, and permuting order changes the responses; that is the point, not an artifact. MultitaperSpectrum is model-free, so overlaying it on the SpectralDensity of the fitted VAR reads misspecification frequency by frequency.

How the library is designed#

One pattern

A model is a validated specification; fit(), identify(), filter(), compute() or date() returns a frozen result. Results carry integer positions and leave your index alone; inputs are anything numpy.asarray() accepts.

Refusals, not silent failure

Malformed shapes raise DimensionError, inconsistent specifications SpecificationError, and estimators that cannot certify their answer NumericalError, each with the numbers that triggered it.

Summaries that argue

Every summary names its identifying assumption, its p-value source (analytic, simulated or bootstrapped), the convention behind its degrees of freedom, and the critique a referee would raise.

Documented to the equation

Each class docstring carries the model in notation, the estimator, the references with page numbers and a doctest that runs in CI. The user guide is where the families are compared; the engine pages are where the numerics are explained.

Citing#

If cultivars contributes to published work, cite the software; a Zenodo DOI will be attached at the first stable release.

@software{sunder_cultivars,
  author  = {Sunder, Nikhil},
  title   = {cultivars: research-grade time series econometrics for Python},
  year    = {2026},
  version = {1.0.0a4},
  url     = {nikhilxsunder/cultivars},
  license = {MIT}
}

The GitHub: CITATION.cff file in the repository carries the same entry in a form GitHub and Zenodo read directly.